diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 7093bf3..0873918 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -73,12 +73,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, checksum: &Checksum) -> io::Result { +pub fn is_current( + path: &Path, + expected_size: Option, + checksum: &Checksum, +) -> io::Result { let metadata = match path.metadata() { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), @@ -104,6 +111,42 @@ fn temp_path(target: &Path) -> Result { Ok(target.with_file_name(format!(".{file_name}.shacraft.part"))) } +/// Replaces `target` with a fully-written temporary sibling. Unix rename +/// replaces an existing file atomically, while Windows rename rejects an +/// existing destination. The backup dance keeps the old file recoverable if +/// the second rename fails (for example because antivirus briefly locks it). +pub(crate) fn replace_file(temporary: &Path, target: &Path) -> io::Result<()> { + #[cfg(not(windows))] + { + fs::rename(temporary, target) + } + #[cfg(windows)] + { + if !target.exists() { + return fs::rename(temporary, target); + } + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "target has no valid filename") + })?; + let backup = target.with_file_name(format!(".{file_name}.shacraft.backup")); + match fs::remove_file(&backup) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + fs::rename(target, &backup)?; + if let Err(error) = fs::rename(temporary, target) { + let _ = fs::rename(&backup, target); + return Err(error); + } + let _ = fs::remove_file(backup); + Ok(()) + } +} + /// 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,18 +169,28 @@ 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); + 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)?; + replace_file(&temporary, target).map_err(DownloadError::Io)?; Ok(bytes) } @@ -160,7 +213,9 @@ fn write_and_verify( if read == 0 { break; } - output.write_all(&buffer[..read]).map_err(DownloadError::Io)?; + output + .write_all(&buffer[..read]) + .map_err(DownloadError::Io)?; sha1.update(&buffer[..read]); sha256.update(&buffer[..read]); bytes += read as u64; @@ -170,7 +225,10 @@ fn write_and_verify( 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()); @@ -183,14 +241,20 @@ fn write_and_verify( #[cfg(test)] mod tests { - use super::{file_hashes, is_current, Checksum}; - use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + use super::{file_hashes, is_current, replace_file, Checksum}; + 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 +265,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 +287,15 @@ 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 replaces_an_existing_file() { + let target = temp_file(b"old"); + let temporary = target.with_extension("replacement"); + fs::write(&temporary, b"new").unwrap(); + replace_file(&temporary, &target).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert!(!temporary.exists()); + fs::remove_file(target).unwrap(); + } } diff --git a/src-tauri/src/java.rs b/src-tauri/src/java.rs index a8e68ce..e96e086 100644 --- a/src-tauri/src/java.rs +++ b/src-tauri/src/java.rs @@ -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 { 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 { +pub fn ensure_java( + client: &Client, + runtime_root: &Path, + required_major: u8, + on_progress: &ProgressCallback, +) -> Result { 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 { diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index a39a118..71ae513 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -69,7 +69,12 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) - .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()); @@ -78,7 +83,11 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) - // (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::>().join(classpath_separator()) + entries + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(classpath_separator()) } /// A persistent-but-not-security-sensitive per-install identifier for the @@ -104,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; @@ -114,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::(); + let hex = bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); crate::session::format_uuid_with_dashes(&hex) } @@ -129,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::>() + .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 @@ -140,7 +188,8 @@ pub fn launch(request: &LaunchRequest) -> Result { 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(); @@ -155,7 +204,10 @@ pub fn launch(request: &LaunchRequest) -> Result { 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()); @@ -168,13 +220,24 @@ pub fn launch(request: &LaunchRequest) -> Result { 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::>(); 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 { @@ -199,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); @@ -217,12 +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")]); + 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\"""# + ); } } diff --git a/src-tauri/src/mojang.rs b/src-tauri/src/mojang.rs index 497bb6c..004cba5 100644 --- a/src-tauri/src/mojang.rs +++ b/src-tauri/src/mojang.rs @@ -27,7 +27,8 @@ use std::{ }; use url::Url; -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", @@ -41,7 +42,10 @@ 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) + Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))) + .unwrap_or(false) } /// Library entries in a merged loader profile may point at the loader's @@ -59,6 +63,7 @@ pub enum MojangError { ChecksumMismatch(String), DisallowedHost(String), MissingField(String), + ConflictingLibrary(String), Download(DownloadError), Io(io::Error), } @@ -70,8 +75,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}"), } @@ -110,7 +121,10 @@ pub fn fetch_version_manifest(client: &Client) -> Result(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) } @@ -145,7 +159,10 @@ pub struct Arguments { #[serde(untagged)] pub enum ArgumentValue { Plain(String), - Conditional { rules: Vec, value: StringOrList }, + Conditional { + rules: Vec, + value: StringOrList, + }, } #[derive(Debug, Deserialize, Clone)] @@ -231,7 +248,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 } @@ -250,7 +271,9 @@ fn os_matches(os: &RuleOs) -> bool { } fn features_match(required: &HashMap, active: &HashMap) -> 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; @@ -265,7 +288,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap) -> 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; } @@ -275,7 +301,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap) -> 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) -> Vec { +pub fn resolve_arguments( + arguments: &[ArgumentValue], + active_features: &HashMap, +) -> Vec { let mut resolved = Vec::new(); for argument in arguments { match argument { @@ -293,11 +322,18 @@ pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap< resolved } -pub fn fetch_version_json(client: &Client, entry: &VersionManifestEntry) -> Result { +pub fn fetch_version_json( + client: &Client, + entry: &VersionManifestEntry, +) -> Result { fetch_json(client, &entry.url, Some(&entry.sha1)) } -fn fetch_json(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result { +fn fetch_json( + client: &Client, + url: &str, + expected_sha1: Option<&str>, +) -> Result { if !is_allowed_host(url) { return Err(MojangError::DisallowedHost(url.to_string())); } @@ -348,8 +384,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 { - let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?; +pub fn merge_versions( + parent: &VersionJson, + child: Option<&VersionJson>, +) -> Result { + let asset_index = parent + .asset_index + .clone() + .ok_or_else(|| MojangError::MissingField("assetIndex".into()))?; let client = parent .downloads .as_ref() @@ -401,34 +443,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 { +pub fn ensure_client_jar( + client: &Client, + game_dir: &Path, + version_id: &str, + download_ref: &DownloadRef, +) -> Result { 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, MojangError> { +pub fn ensure_libraries( + client: &Client, + game_dir: &Path, + libraries: &[Library], + on_progress: &ProgressCallback, +) -> Result, MojangError> { let mut paths = Vec::new(); let mut tasks = Vec::new(); + let mut seen: HashMap = 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(), @@ -452,20 +529,39 @@ pub struct AssetObject { pub size: u64, } -pub fn ensure_asset_index(client: &Client, game_dir: &Path, asset_index: &AssetIndexRef) -> Result { +pub fn ensure_asset_index( + client: &Client, + game_dir: &Path, + asset_index: &AssetIndexRef, +) -> Result { 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 @@ -473,7 +569,10 @@ 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()), @@ -500,7 +599,14 @@ const MAX_DOWNLOAD_ATTEMPTS: u32 = 5; fn download_with_retries(client: &Client, task: &DownloadTask) -> Result { 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); @@ -540,12 +646,16 @@ fn download_many( if first_error.lock().unwrap().is_some() { break; } - let Some(task) = queue.lock().unwrap().pop() else { break }; + 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)); @@ -571,7 +681,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, } } @@ -589,7 +702,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())); } @@ -598,7 +715,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())); @@ -619,7 +740,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] @@ -649,18 +773,38 @@ 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!["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!["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_library_host("https://maven.neoforged.net/releases/net/neoforged/example.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")); } @@ -677,7 +821,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(); @@ -689,11 +834,20 @@ mod tests { let mut small_libraries: Vec = 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(); @@ -703,7 +857,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(); diff --git a/src-tauri/src/msa.rs b/src-tauri/src/msa.rs index e215657..362c46e 100644 --- a/src-tauri/src/msa.rs +++ b/src-tauri/src/msa.rs @@ -41,7 +41,8 @@ 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"; @@ -111,7 +112,10 @@ pub fn start_device_code(client: &Client) -> Result { } 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() { @@ -144,7 +148,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 { +pub fn poll_device_code( + client: &Client, + start: &DeviceCodeStart, +) -> Result { let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds); let mut interval = Duration::from_secs(start.interval_seconds); @@ -167,10 +174,16 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result Result 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 { +pub fn refresh_microsoft_tokens( + client: &Client, + refresh_token: &str, +) -> Result { if !is_configured() { return Err(MsaError::NotConfigured); } @@ -205,9 +225,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, + }) } // --------------------------------------------------------------------- @@ -274,7 +299,10 @@ struct XboxUserHash { xid: Option, } -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", @@ -284,22 +312,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), MsaError> { +fn xsts_authorize( + client: &Client, + xbox_live_token: &str, +) -> Result<(String, String, Option), 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 @@ -312,7 +360,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)) } @@ -328,8 +381,14 @@ struct MinecraftLoginResponse { } fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result { - 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())); } @@ -347,7 +406,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 { +fn fetch_minecraft_profile( + client: &Client, + minecraft_access_token: &str, +) -> Result { let response = client .get(MINECRAFT_PROFILE_URL) .bearer_auth(minecraft_access_token) @@ -376,15 +438,26 @@ fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result Result { +pub fn login_with_device_code( + client: &Client, + start: &DeviceCodeStart, +) -> Result { let tokens = poll_device_code(client, start)?; complete_login(client, tokens) } -pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result { +pub fn login_with_refresh_token( + client: &Client, + refresh_token: &str, +) -> Result { let tokens = refresh_microsoft_tokens(client, refresh_token)?; complete_login(client, tokens) } @@ -401,8 +474,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); let temporary = data_dir.join(".account.json.shacraft.part"); @@ -412,7 +492,7 @@ pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<() use std::os::unix::fs::PermissionsExt; fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; } - fs::rename(temporary, target) + crate::download::replace_file(&temporary, &target) } pub fn load_refresh_token(data_dir: &Path) -> Option { @@ -438,7 +518,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,9 +531,14 @@ 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(); } @@ -459,7 +547,10 @@ mod tests { fn refuses_to_run_with_placeholder_client_id() { 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 diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index a66bac6..436343b 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -57,21 +57,34 @@ pub enum NeoForgeError { Download(DownloadError), Io(io::Error), InvalidJson(serde_json::Error), - InstallerFailed { exit_code: Option, output_tail: String }, + InstallerFailed { + exit_code: Option, + 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}" + ) } } } @@ -89,7 +102,10 @@ impl From for NeoForgeError { } pub(crate) 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) + Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)) + .unwrap_or(false) } fn installer_jar_url(loader_version: &str) -> String { @@ -99,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 { +pub fn ensure_installer( + client: &Client, + cache_dir: &Path, + loader_version: &str, +) -> Result { 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)); } @@ -139,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. @@ -161,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); @@ -173,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::().chars().rev().collect() + text.chars() + .rev() + .take(4000) + .collect::() + .chars() + .rev() + .collect() } /// Runs the installer with piped output, reporting live progress as its own @@ -219,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'); @@ -243,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)) } @@ -258,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 { +pub fn ensure_client_installed( + client: &Client, + java_executable: &Path, + game_dir: &Path, + cache_dir: &Path, + loader_version: &str, + on_progress: &ProgressCallback, +) -> Result { 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 { @@ -296,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(); @@ -312,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); @@ -326,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)]); } @@ -350,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(); @@ -360,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. @@ -369,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(); } diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index f7e4808..40c73aa 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -1,14 +1,17 @@ use crate::manifest::{self, Manifest}; use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use reqwest::header::ACCEPT_ENCODING; use reqwest::{blocking::Client, redirect::Policy}; use serde::{Deserialize, Serialize}; -use std::{fmt, time::Duration}; +use std::{fmt, 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 MAX_MANIFEST_SIZE: u64 = 2 * 1024 * 1024; +const MANIFEST_ATTEMPTS: u32 = 3; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -47,7 +50,9 @@ impl fmt::Display for RemoteError { Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"), Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"), Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"), - Self::InvalidManifest(error) => write!(formatter, "ShaCraft manifest is invalid: {error}"), + Self::InvalidManifest(error) => { + write!(formatter, "ShaCraft manifest is invalid: {error}") + } } } } @@ -62,26 +67,63 @@ pub fn fetch_manifest(profile_id: &str) -> Result { .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())); + let mut last_network_error = None; + let mut source = None; + for attempt in 1..=MANIFEST_ATTEMPTS { + match client.get(url).header(ACCEPT_ENCODING, "identity").send() { + Ok(response) => { + if !response.status().is_success() { + return Err(RemoteError::Status(response.status())); + } + if response + .content_length() + .is_some_and(|size| size > MAX_MANIFEST_SIZE) + { + return Err(RemoteError::TooLarge); + } + match response.bytes() { + Ok(bytes) if bytes.len() as u64 <= MAX_MANIFEST_SIZE => { + source = Some(bytes); + break; + } + Ok(_) => return Err(RemoteError::TooLarge), + Err(error) => last_network_error = Some(error), + } + } + Err(error) => last_network_error = Some(error), + } + if attempt < MANIFEST_ATTEMPTS { + thread::sleep(Duration::from_millis(250 * attempt as u64)); + } } - if response.content_length().is_some_and(|size| size > 2 * 1024 * 1024) { - return Err(RemoteError::TooLarge); - } - let source = response.text().map_err(RemoteError::Network)?; - let envelope = serde_json::from_str::(&source) + let source = source.ok_or_else(|| { + RemoteError::Network(last_network_error.expect("a network attempt failed")) + })?; + let envelope = serde_json::from_slice::(&source) .map_err(|_| RemoteError::InvalidSignature)?; if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" { 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")) + 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 signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?; - public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?; + 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 = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest) } @@ -100,5 +142,7 @@ pub fn fetch_server_status(profile_id: &str) -> Result().map_err(RemoteError::Network) + response + .json::() + .map_err(RemoteError::Network) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index acf5645..e5422ae 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,3 +1,4 @@ +use crate::download; use serde::{Deserialize, Serialize}; use std::{fmt, fs, io, path::Path}; @@ -38,7 +39,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 +60,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 +75,9 @@ pub fn load(data_dir: &Path) -> Result { 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,7 +85,10 @@ pub fn load(data_dir: &Path) -> Result { Ok(settings) } -pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result { +pub fn save( + data_dir: &Path, + settings: LauncherSettings, +) -> Result { validate(&settings)?; fs::create_dir_all(data_dir).map_err(SettingsError::Io)?; @@ -81,15 +96,22 @@ pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result 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 +120,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 +144,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 +165,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] diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs index 558e9e7..26d7b03 100644 --- a/src-tauri/src/shacraft_account.rs +++ b/src-tauri/src/shacraft_account.rs @@ -72,7 +72,9 @@ impl fmt::Display for AccountError { 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"), + Self::NoLinkedNickname => { + formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics") + } } } } @@ -87,9 +89,14 @@ fn client() -> Result { fn api_error(response: Response) -> AccountError { #[derive(Deserialize)] - struct ErrorBody { detail: Option } + struct ErrorBody { + detail: Option, + } let status = response.status(); - let detail = response.json::().ok().and_then(|body| body.detail); + let detail = response + .json::() + .ok() + .and_then(|body| body.detail); AccountError::Api(detail.unwrap_or_else(|| format!("ShaCraft API: HTTP {status}"))) } @@ -105,14 +112,19 @@ fn save_session(data_dir: &Path, token: &str) -> Result<(), AccountError> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)).map_err(AccountError::Io)?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)) + .map_err(AccountError::Io)?; } - fs::rename(temporary, path).map_err(AccountError::Io) + crate::download::replace_file(&temporary, &path).map_err(AccountError::Io) } fn load_session(data_dir: &Path) -> Result { 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) } + 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()) { @@ -121,32 +133,59 @@ fn load_session(data_dir: &Path) -> Result { Ok(token.to_owned()) } -pub fn authenticate(data_dir: &Path, username: &str, password: &str, register: bool) -> Result { - 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::().map_err(AccountError::Network)?; +pub fn authenticate( + data_dir: &Path, + username: &str, + password: &str, + register: bool, +) -> Result { + 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::() + .map_err(AccountError::Network)?; save_session(data_dir, &payload.session_token)?; - Ok(LoginResult { account: payload.account, recovery_codes: payload.recovery_codes }) + Ok(LoginResult { + account: payload.account, + recovery_codes: payload.recovery_codes, + }) } pub fn get_account(data_dir: &Path) -> Result { let token = load_session(data_dir)?; - let response = client()?.get(format!("{API_ORIGIN}/api/launcher/account")) - .bearer_auth(token).send().map_err(AccountError::Network)?; + 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)); } + if !response.status().is_success() { + return Err(api_error(response)); + } response.json::().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(); + 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(()), @@ -155,25 +194,43 @@ pub fn logout(data_dir: &Path) -> Result<(), AccountError> { } } -pub fn start_link(data_dir: &Path, server_id: &str, nickname: &str) -> Result { +pub fn start_link( + data_dir: &Path, + server_id: &str, + nickname: &str, +) -> Result { 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)); } + 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::().map_err(AccountError::Network) } pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result { 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)); } + 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::().map_err(AccountError::Network) } pub fn aeronautics_nickname(data_dir: &Path) -> Result { - get_account(data_dir)?.links.into_iter() + get_account(data_dir)? + .links + .into_iter() .find(|link| link.server_id == "aoc") .map(|link| link.mc_username) .ok_or(AccountError::NoLinkedNickname) @@ -182,13 +239,19 @@ pub fn aeronautics_nickname(data_dir: &Path) -> Result { #[cfg(test)] mod tests { use super::{load_session, save_session, session_path}; - 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-account-test-{}-{}", process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )) } @@ -208,7 +271,14 @@ mod tests { 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); + assert_eq!( + fs::metadata(session_path(&directory)) + .unwrap() + .permissions() + .mode() + & 0o077, + 0 + ); fs::remove_dir_all(directory).unwrap(); } }