fix: harden Windows install and launch pipeline

This commit is contained in:
Emil
2026-09-07 22:41:59 +03:00
parent 5fa58b0013
commit ff666ba55f
9 changed files with 1002 additions and 205 deletions
+89 -11
View File
@@ -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<u64>, checksum: &Checksum) -> io::Result<bool> {
pub fn is_current(
path: &Path,
expected_size: Option<u64>,
checksum: &Checksum,
) -> io::Result<bool> {
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
@@ -104,6 +111,42 @@ fn temp_path(target: &Path) -> Result<PathBuf, DownloadError> {
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();
}
}
+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> {
+106 -15
View File
@@ -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::<Vec<_>>().join(classpath_separator())
entries
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.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::<String>();
let hex = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
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::<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
@@ -140,7 +188,8 @@ 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();
@@ -155,7 +204,10 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
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<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 {
@@ -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\"""#
);
}
}
+195 -40
View File
@@ -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<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)
}
@@ -145,7 +159,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)]
@@ -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<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;
@@ -265,7 +288,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;
}
@@ -275,7 +301,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 {
@@ -293,11 +322,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()));
}
@@ -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<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()
@@ -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<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(),
@@ -452,20 +529,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
@@ -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<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);
@@ -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<_>>(), 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_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<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();
@@ -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();
+121 -30
View File
@@ -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<DeviceCodeStart, MsaError> {
}
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<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);
@@ -167,10 +174,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() {
@@ -181,12 +194,19 @@ 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> {
pub fn refresh_microsoft_tokens(
client: &Client,
refresh_token: &str,
) -> Result<MicrosoftTokens, MsaError> {
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<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",
@@ -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<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
@@ -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<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()));
}
@@ -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<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)
@@ -376,15 +438,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)
}
@@ -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<String> {
@@ -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
+235 -39
View File
@@ -57,21 +57,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}"
)
}
}
}
@@ -89,7 +102,10 @@ impl From<io::Error> 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<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));
}
@@ -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::<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
@@ -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<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 {
@@ -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();
}
+61 -17
View File
@@ -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<Manifest, RemoteError> {
.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::<SignedManifest>(&source)
let source = source.ok_or_else(|| {
RemoteError::Network(last_network_error.expect("a network attempt failed"))
})?;
let envelope = serde_json::from_slice::<SignedManifest>(&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<ServerStatus, RemoteError
if !response.status().is_success() {
return Err(RemoteError::Status(response.status()));
}
response.json::<ServerStatus>().map_err(RemoteError::Network)
response
.json::<ServerStatus>()
.map_err(RemoteError::Network)
}
+69 -14
View File
@@ -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<LauncherSettings, SettingsError> {
let path = data_dir.join(SETTINGS_FILE);
let source = match fs::read_to_string(path) {
Ok(source) => source,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(LauncherSettings::default()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(LauncherSettings::default())
}
Err(error) => return Err(SettingsError::Io(error)),
};
let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?;
@@ -73,7 +85,10 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
Ok(settings)
}
pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result<LauncherSettings, SettingsError> {
pub fn save(
data_dir: &Path,
settings: LauncherSettings,
) -> Result<LauncherSettings, SettingsError> {
validate(&settings)?;
fs::create_dir_all(data_dir).map_err(SettingsError::Io)?;
@@ -81,15 +96,22 @@ pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result<LauncherSetti
let temporary = data_dir.join(".settings.json.shacraft.part");
let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
fs::write(&temporary, contents).map_err(SettingsError::Io)?;
fs::rename(temporary, target).map_err(SettingsError::Io)?;
download::replace_file(&temporary, &target).map_err(SettingsError::Io)?;
Ok(settings)
}
fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) || settings.memory_mb % 1024 != 0 {
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb)
|| settings.memory_mb % 1024 != 0
{
return Err(SettingsError::InvalidMemory);
}
if !(3..=16).contains(&settings.nickname.len()) || !settings.nickname.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
if !(3..=16).contains(&settings.nickname.len())
|| !settings
.nickname
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
return Err(SettingsError::InvalidNickname);
}
Ok(())
@@ -98,13 +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]
+100 -30
View File
@@ -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<Client, AccountError> {
fn api_error(response: Response) -> AccountError {
#[derive(Deserialize)]
struct ErrorBody { detail: Option<String> }
struct ErrorBody {
detail: Option<String>,
}
let status = response.status();
let detail = response.json::<ErrorBody>().ok().and_then(|body| body.detail);
let detail = response
.json::<ErrorBody>()
.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<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) }
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<String, AccountError> {
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)?;
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 })
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)?;
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::<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();
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<LinkStart, AccountError> {
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)); }
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)); }
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()
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<String, AccountError> {
#[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();
}
}