fix: harden Windows install and launch pipeline
This commit is contained in:
+89
-11
@@ -73,12 +73,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> {
|
|||||||
sha1.update(&buffer[..read]);
|
sha1.update(&buffer[..read]);
|
||||||
sha256.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
|
/// True if `path` already exists, matches `expected_size` (when given) and
|
||||||
/// `checksum`. Used to skip re-downloading files that are already current.
|
/// `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() {
|
let metadata = match path.metadata() {
|
||||||
Ok(metadata) => metadata,
|
Ok(metadata) => metadata,
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
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")))
|
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
|
/// Downloads `url` to `target`, verifying size (if known ahead of time) and
|
||||||
/// `checksum` before atomically renaming the temporary file into place.
|
/// `checksum` before atomically renaming the temporary file into place.
|
||||||
/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk;
|
/// `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());
|
let total = expected_size.or_else(|| response.content_length());
|
||||||
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
|
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
|
||||||
if expected != length {
|
if expected != length {
|
||||||
return Err(DownloadError::SizeMismatch { expected, actual: length });
|
return Err(DownloadError::SizeMismatch {
|
||||||
|
expected,
|
||||||
|
actual: length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let temporary = temp_path(target)?;
|
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 {
|
if let Err(error) = result {
|
||||||
let _ = fs::remove_file(&temporary);
|
let _ = fs::remove_file(&temporary);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
let bytes = result.unwrap();
|
let bytes = result.unwrap();
|
||||||
fs::rename(&temporary, target).map_err(DownloadError::Io)?;
|
replace_file(&temporary, target).map_err(DownloadError::Io)?;
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +213,9 @@ fn write_and_verify(
|
|||||||
if read == 0 {
|
if read == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
output.write_all(&buffer[..read]).map_err(DownloadError::Io)?;
|
output
|
||||||
|
.write_all(&buffer[..read])
|
||||||
|
.map_err(DownloadError::Io)?;
|
||||||
sha1.update(&buffer[..read]);
|
sha1.update(&buffer[..read]);
|
||||||
sha256.update(&buffer[..read]);
|
sha256.update(&buffer[..read]);
|
||||||
bytes += read as u64;
|
bytes += read as u64;
|
||||||
@@ -170,7 +225,10 @@ fn write_and_verify(
|
|||||||
|
|
||||||
if let Some(expected) = expected_size {
|
if let Some(expected) = expected_size {
|
||||||
if bytes != expected {
|
if bytes != expected {
|
||||||
return Err(DownloadError::SizeMismatch { expected, actual: bytes });
|
return Err(DownloadError::SizeMismatch {
|
||||||
|
expected,
|
||||||
|
actual: bytes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sha1_hex = format!("{:x}", sha1.finalize());
|
let sha1_hex = format!("{:x}", sha1.finalize());
|
||||||
@@ -183,14 +241,20 @@ fn write_and_verify(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{file_hashes, is_current, Checksum};
|
use super::{file_hashes, is_current, replace_file, Checksum};
|
||||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
use std::{
|
||||||
|
fs, process,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
|
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
"shacraft-download-test-{}-{}",
|
"shacraft-download-test-{}-{}",
|
||||||
process::id(),
|
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();
|
fs::write(&path, contents).unwrap();
|
||||||
path
|
path
|
||||||
@@ -201,7 +265,10 @@ mod tests {
|
|||||||
let path = temp_file(b"hello shacraft");
|
let path = temp_file(b"hello shacraft");
|
||||||
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
|
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
|
||||||
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
|
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
|
||||||
assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d");
|
assert_eq!(
|
||||||
|
sha256_hex,
|
||||||
|
"d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"
|
||||||
|
);
|
||||||
fs::remove_file(path).unwrap();
|
fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,4 +287,15 @@ mod tests {
|
|||||||
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
|
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
|
||||||
assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap());
|
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
@@ -2,7 +2,11 @@ use crate::download::ProgressCallback;
|
|||||||
use crate::runtime::{self, RuntimeError};
|
use crate::runtime::{self, RuntimeError};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde::Serialize;
|
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)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -21,9 +25,14 @@ pub enum EnsureJavaError {
|
|||||||
impl fmt::Display for EnsureJavaError {
|
impl fmt::Display for EnsureJavaError {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
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) => {
|
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)
|
candidates().into_iter().find_map(check_candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a Java runtime with at least `required_major`, preferring
|
/// Returns a Java runtime with exactly `required_major`, preferring a matching
|
||||||
/// whatever the user already has installed. Only downloads and extracts a
|
/// 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
|
/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches
|
||||||
/// the user's own Java) when nothing suitable is already on the machine.
|
/// the user's own Java) when nothing suitable is already on the machine.
|
||||||
/// `on_progress` reports real download bytes when a JRE actually needs
|
/// `on_progress` reports real download bytes when a JRE actually needs
|
||||||
/// fetching; it fires once with `(1, 1)` when an existing Java is reused.
|
/// 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 let Some(installation) = detect() {
|
||||||
if installation.major >= required_major {
|
if installation.major == required_major {
|
||||||
on_progress(1, 1);
|
on_progress(1, 1);
|
||||||
return Ok(installation);
|
return Ok(installation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?;
|
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
|
||||||
check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
.map_err(EnsureJavaError::Provisioning)?;
|
||||||
|
check_candidate(executable.clone())
|
||||||
|
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn candidates() -> Vec<PathBuf> {
|
fn candidates() -> Vec<PathBuf> {
|
||||||
|
|||||||
+106
-15
@@ -69,7 +69,12 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -
|
|||||||
.libraries
|
.libraries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|library| mojang::rule_allows(&library.rules, &no_features))
|
.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))
|
.map(|artifact| game_dir.join("libraries").join(&artifact.path))
|
||||||
.collect();
|
.collect();
|
||||||
entries.push(client_jar.to_path_buf());
|
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
|
// (for example on gson-2.10.1.jar), so preserve order and keep each path
|
||||||
// only once.
|
// only once.
|
||||||
let entries = unique_classpath_entries(entries);
|
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
|
/// 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 {
|
fn random_uuid_v4() -> String {
|
||||||
let mut hasher = Sha256::new();
|
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(std::process::id().to_le_bytes());
|
||||||
hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
|
hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
|
||||||
let stack_marker = 0_u8;
|
let stack_marker = 0_u8;
|
||||||
@@ -114,7 +129,10 @@ fn random_uuid_v4() -> String {
|
|||||||
bytes.copy_from_slice(&digest[0..16]);
|
bytes.copy_from_slice(&digest[0..16]);
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
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)
|
crate::session::format_uuid_with_dashes(&hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +147,36 @@ fn substitute(template: &str, vars: &HashMap<&str, String>) -> String {
|
|||||||
result
|
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
|
/// Builds the full `java` command line for `request.merged` and spawns it
|
||||||
/// detached, with stdout/stderr both redirected to `request.log_path`.
|
/// detached, with stdout/stderr both redirected to `request.log_path`.
|
||||||
/// Never blocks on the child exiting — the caller decides how to observe
|
/// 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)?;
|
fs::create_dir_all(&natives_dir)?;
|
||||||
let assets_root = request.game_dir.join("assets");
|
let assets_root = request.game_dir.join("assets");
|
||||||
let libraries_dir = request.game_dir.join("libraries");
|
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 classpath = build_classpath(request.game_dir, request.merged, &client_jar);
|
||||||
|
|
||||||
let mut vars: HashMap<&str, String> = HashMap::new();
|
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_root", assets_root.display().to_string());
|
||||||
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
||||||
vars.insert("auth_uuid", request.identity.uuid());
|
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("clientid", launcher_client_id(request.game_dir)?);
|
||||||
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
||||||
vars.insert("user_type", request.identity.user_type().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());
|
vars.insert("classpath_separator", classpath_separator().to_string());
|
||||||
|
|
||||||
let no_features = HashMap::new();
|
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 game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
|
||||||
|
|
||||||
let mut command = Command::new(request.java_executable);
|
let mut command = Command::new(request.java_executable);
|
||||||
command.arg(format!("-Xmx{}M", request.memory_mb));
|
let memory_argument = format!("-Xmx{}M", request.memory_mb);
|
||||||
for argument in jvm_args {
|
if cfg!(windows) {
|
||||||
command.arg(substitute(&argument, &vars));
|
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);
|
command.arg(&request.merged.main_class);
|
||||||
for argument in game_args {
|
for argument in game_args {
|
||||||
@@ -199,12 +262,18 @@ mod tests {
|
|||||||
let parts: Vec<&str> = id.split('-').collect();
|
let parts: Vec<&str> = id.split('-').collect();
|
||||||
assert_eq!(parts.len(), 5);
|
assert_eq!(parts.len(), 5);
|
||||||
assert_eq!(parts[2].chars().next().unwrap(), '4');
|
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]
|
#[test]
|
||||||
fn client_id_is_persisted_across_calls() {
|
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 first = launcher_client_id(&dir).unwrap();
|
||||||
let second = launcher_client_id(&dir).unwrap();
|
let second = launcher_client_id(&dir).unwrap();
|
||||||
assert_eq!(first, second);
|
assert_eq!(first, second);
|
||||||
@@ -217,12 +286,34 @@ mod tests {
|
|||||||
vars.insert("auth_player_name", "Steve".to_string());
|
vars.insert("auth_player_name", "Steve".to_string());
|
||||||
assert_eq!(substitute("--username", &vars), "--username");
|
assert_eq!(substitute("--username", &vars), "--username");
|
||||||
assert_eq!(substitute("${auth_player_name}", &vars), "Steve");
|
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]
|
#[test]
|
||||||
fn classpath_entries_are_unique() {
|
fn classpath_entries_are_unique() {
|
||||||
let entries = unique_classpath_entries(vec![PathBuf::from("gson.jar"), PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
|
let entries = unique_classpath_entries(vec![
|
||||||
assert_eq!(entries, vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
|
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
@@ -27,7 +27,8 @@ use std::{
|
|||||||
};
|
};
|
||||||
use url::Url;
|
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] = [
|
const MOJANG_HOSTS: [&str; 4] = [
|
||||||
"piston-meta.mojang.com",
|
"piston-meta.mojang.com",
|
||||||
"piston-data.mojang.com",
|
"piston-data.mojang.com",
|
||||||
@@ -41,7 +42,10 @@ const MOJANG_HOSTS: [&str; 4] = [
|
|||||||
const ASSET_WORKERS: usize = 48;
|
const ASSET_WORKERS: usize = 48;
|
||||||
|
|
||||||
pub fn is_allowed_host(url: &str) -> bool {
|
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
|
/// Library entries in a merged loader profile may point at the loader's
|
||||||
@@ -59,6 +63,7 @@ pub enum MojangError {
|
|||||||
ChecksumMismatch(String),
|
ChecksumMismatch(String),
|
||||||
DisallowedHost(String),
|
DisallowedHost(String),
|
||||||
MissingField(String),
|
MissingField(String),
|
||||||
|
ConflictingLibrary(String),
|
||||||
Download(DownloadError),
|
Download(DownloadError),
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
}
|
}
|
||||||
@@ -70,8 +75,14 @@ impl fmt::Display for MojangError {
|
|||||||
Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"),
|
Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"),
|
||||||
Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"),
|
Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"),
|
||||||
Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"),
|
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::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::Download(error) => write!(formatter, "{error}"),
|
||||||
Self::Io(error) => write!(formatter, "I/O error: {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)
|
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)
|
manifest.versions.iter().find(|entry| entry.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +159,10 @@ pub struct Arguments {
|
|||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum ArgumentValue {
|
pub enum ArgumentValue {
|
||||||
Plain(String),
|
Plain(String),
|
||||||
Conditional { rules: Vec<Rule>, value: StringOrList },
|
Conditional {
|
||||||
|
rules: Vec<Rule>,
|
||||||
|
value: StringOrList,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -231,7 +248,11 @@ fn current_os_name() -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn arch_matches(expected: &str) -> bool {
|
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
|
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 {
|
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;
|
/// 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;
|
let mut allowed = false;
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
let os_ok = rule.os.as_ref().is_none_or(os_matches);
|
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 {
|
if os_ok && features_ok {
|
||||||
allowed = rule.action == RuleAction::Allow;
|
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
|
/// Flattens an argument list into plain strings, dropping conditional
|
||||||
/// entries whose rules don't match this platform/feature set.
|
/// 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();
|
let mut resolved = Vec::new();
|
||||||
for argument in arguments {
|
for argument in arguments {
|
||||||
match argument {
|
match argument {
|
||||||
@@ -293,11 +322,18 @@ pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<
|
|||||||
resolved
|
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))
|
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) {
|
if !is_allowed_host(url) {
|
||||||
return Err(MojangError::DisallowedHost(url.to_string()));
|
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.
|
/// the parent's, and its libraries are appended after the parent's.
|
||||||
/// `assetIndex`/`downloads.client` always come from the parent, since
|
/// `assetIndex`/`downloads.client` always come from the parent, since
|
||||||
/// modloader profiles don't redeclare them.
|
/// modloader profiles don't redeclare them.
|
||||||
pub fn merge_versions(parent: &VersionJson, child: Option<&VersionJson>) -> Result<MergedVersion, MojangError> {
|
pub fn merge_versions(
|
||||||
let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
|
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
|
let client = parent
|
||||||
.downloads
|
.downloads
|
||||||
.as_ref()
|
.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 {
|
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) {
|
if !is_allowed_host(&download_ref.url) {
|
||||||
return Err(MojangError::DisallowedHost(download_ref.url.clone()));
|
return Err(MojangError::DisallowedHost(download_ref.url.clone()));
|
||||||
}
|
}
|
||||||
let target = client_jar_path(game_dir, version_id);
|
let target = client_jar_path(game_dir, version_id);
|
||||||
let checksum = Checksum::Sha1(download_ref.sha1.clone());
|
let checksum = Checksum::Sha1(download_ref.sha1.clone());
|
||||||
if !download::is_current(&target, Some(download_ref.size), &checksum)? {
|
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)
|
Ok(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads every rule-allowed library with a `downloads.artifact`,
|
/// Downloads every rule-allowed library with a `downloads.artifact`,
|
||||||
/// returning the resulting jar paths in the same order as `libraries`.
|
/// 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 paths = Vec::new();
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
|
let mut seen: HashMap<PathBuf, (String, u64, String)> = HashMap::new();
|
||||||
for library in libraries {
|
for library in libraries {
|
||||||
if !rule_allows(&library.rules, &HashMap::new()) {
|
if !rule_allows(&library.rules, &HashMap::new()) {
|
||||||
continue;
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let target = game_dir.join("libraries").join(&artifact.path);
|
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());
|
paths.push(target.clone());
|
||||||
tasks.push(DownloadTask {
|
tasks.push(DownloadTask {
|
||||||
url: artifact.url.clone(),
|
url: artifact.url.clone(),
|
||||||
@@ -452,20 +529,39 @@ pub struct AssetObject {
|
|||||||
pub size: u64,
|
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) {
|
if !is_allowed_host(&asset_index.url) {
|
||||||
return Err(MojangError::DisallowedHost(asset_index.url.clone()));
|
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());
|
let checksum = Checksum::Sha1(asset_index.sha1.clone());
|
||||||
if !download::is_current(&target, Some(asset_index.size), &checksum)? {
|
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)?;
|
let bytes = fs::read(&target)?;
|
||||||
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
|
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 objects_dir = game_dir.join("assets").join("objects");
|
||||||
let tasks = index
|
let tasks = index
|
||||||
.objects
|
.objects
|
||||||
@@ -473,7 +569,10 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr
|
|||||||
.map(|object| {
|
.map(|object| {
|
||||||
let prefix = &object.hash[0..2];
|
let prefix = &object.hash[0..2];
|
||||||
DownloadTask {
|
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),
|
target: objects_dir.join(prefix).join(&object.hash),
|
||||||
size: object.size,
|
size: object.size,
|
||||||
checksum: Checksum::Sha1(object.hash.clone()),
|
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> {
|
fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, DownloadError> {
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS {
|
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),
|
Ok(bytes) => return Ok(bytes),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
last_error = Some(error);
|
last_error = Some(error);
|
||||||
@@ -540,12 +646,16 @@ fn download_many(
|
|||||||
if first_error.lock().unwrap().is_some() {
|
if first_error.lock().unwrap().is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Some(task) = queue.lock().unwrap().pop() else { break };
|
let Some(task) = queue.lock().unwrap().pop() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
if !is_allowed(&task.url) {
|
if !is_allowed(&task.url) {
|
||||||
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
|
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
|
||||||
continue;
|
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 !already_current {
|
||||||
if let Err(error) = download_with_retries(client, &task) {
|
if let Err(error) = download_with_retries(client, &task) {
|
||||||
*first_error.lock().unwrap() = Some(MojangError::Download(error));
|
*first_error.lock().unwrap() = Some(MojangError::Download(error));
|
||||||
@@ -571,7 +681,10 @@ mod tests {
|
|||||||
fn rule(action: RuleAction, os_name: Option<&str>) -> Rule {
|
fn rule(action: RuleAction, os_name: Option<&str>) -> Rule {
|
||||||
Rule {
|
Rule {
|
||||||
action,
|
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,
|
features: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,7 +702,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_matching_os_rule_disallows() {
|
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))];
|
let rules = vec![rule(RuleAction::Allow, Some(other))];
|
||||||
assert!(!rule_allows(&rules, &HashMap::new()));
|
assert!(!rule_allows(&rules, &HashMap::new()));
|
||||||
}
|
}
|
||||||
@@ -598,7 +715,11 @@ mod tests {
|
|||||||
fn unsupported_feature_is_excluded_by_default() {
|
fn unsupported_feature_is_excluded_by_default() {
|
||||||
let mut features = HashMap::new();
|
let mut features = HashMap::new();
|
||||||
features.insert("is_demo_user".to_string(), true);
|
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
|
// We never activate optional features, so a rule requiring one
|
||||||
// must not match even though there's no OS constraint.
|
// must not match even though there's no OS constraint.
|
||||||
assert!(!rule_allows(&rules, &HashMap::new()));
|
assert!(!rule_allows(&rules, &HashMap::new()));
|
||||||
@@ -619,7 +740,10 @@ mod tests {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
let resolved = resolve_arguments(&args, &HashMap::new());
|
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]
|
#[test]
|
||||||
@@ -649,18 +773,38 @@ mod tests {
|
|||||||
let merged = merge_versions(&parent, Some(&child)).unwrap();
|
let merged = merge_versions(&parent, Some(&child)).unwrap();
|
||||||
assert_eq!(merged.id, "neoforge-21.1.248");
|
assert_eq!(merged.id, "neoforge-21.1.248");
|
||||||
assert_eq!(merged.client_jar_version_id, "1.21.1");
|
assert_eq!(merged.client_jar_version_id, "1.21.1");
|
||||||
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
|
assert_eq!(
|
||||||
assert_eq!(resolve_arguments(&merged.game_arguments, &HashMap::new()), vec!["--parentGame", "--childGame"]);
|
merged.main_class,
|
||||||
assert_eq!(resolve_arguments(&merged.jvm_arguments, &HashMap::new()), vec!["--parentJvm", "--childJvm"]);
|
"cpw.mods.bootstraplauncher.BootstrapLauncher"
|
||||||
assert_eq!(merged.libraries.iter().map(|library| library.name.as_str()).collect::<Vec<_>>(), vec!["parent:lib:1", "child:lib:1"]);
|
);
|
||||||
|
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");
|
assert_eq!(merged.asset_index.id, "17");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disallowed_host_is_rejected() {
|
fn disallowed_host_is_rejected() {
|
||||||
assert!(!is_allowed_host("https://example.com/evil.jar"));
|
assert!(!is_allowed_host("https://example.com/evil.jar"));
|
||||||
assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar"));
|
assert!(is_allowed_host(
|
||||||
assert!(is_allowed_library_host("https://maven.neoforged.net/releases/net/neoforged/example.jar"));
|
"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"));
|
assert!(!is_allowed_library_host("https://example.com/evil.jar"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +821,8 @@ mod tests {
|
|||||||
let version = fetch_version_json(&client, entry).unwrap();
|
let version = fetch_version_json(&client, entry).unwrap();
|
||||||
assert_eq!(version.main_class, "net.minecraft.client.main.Main");
|
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 merged = merge_versions(&version, None).unwrap();
|
||||||
let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).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
|
let mut small_libraries: Vec<Library> = merged
|
||||||
.libraries
|
.libraries
|
||||||
.iter()
|
.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)
|
.take(5)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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);
|
small_libraries.truncate(5);
|
||||||
let progress: ProgressCallback = Arc::new(|_, _| {});
|
let progress: ProgressCallback = Arc::new(|_, _| {});
|
||||||
let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap();
|
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
|
// Re-running against already-downloaded files must be a no-op (the
|
||||||
// `is_current` fast path), not re-download or fail.
|
// `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);
|
assert_eq!(client_jar, client_jar_again);
|
||||||
|
|
||||||
fs::remove_dir_all(&game_dir).ok();
|
fs::remove_dir_all(&game_dir).ok();
|
||||||
|
|||||||
+121
-30
@@ -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 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 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 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 MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||||
const ACCOUNT_FILE: &str = "account.json";
|
const ACCOUNT_FILE: &str = "account.json";
|
||||||
|
|
||||||
@@ -111,7 +112,10 @@ pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
|
|||||||
}
|
}
|
||||||
let response = client
|
let response = client
|
||||||
.post(DEVICE_CODE_URL)
|
.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()
|
.send()
|
||||||
.map_err(MsaError::Network)?;
|
.map_err(MsaError::Network)?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
@@ -144,7 +148,10 @@ struct TokenResponse {
|
|||||||
/// decline. This is the slow step in the whole login flow — the caller
|
/// decline. This is the slow step in the whole login flow — the caller
|
||||||
/// should already have shown `verification_uri`/`user_code` to the user
|
/// should already have shown `verification_uri`/`user_code` to the user
|
||||||
/// before calling this (see `start_device_code`).
|
/// 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 deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds);
|
||||||
let mut interval = Duration::from_secs(start.interval_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)?;
|
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
|
||||||
|
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
|
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token)
|
||||||
return Err(MsaError::UnexpectedResponse("token response missing access_token/refresh_token".into()));
|
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() {
|
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("authorization_declined") => return Err(MsaError::AuthorizationDeclined),
|
||||||
Some("expired_token") => return Err(MsaError::AuthorizationExpired),
|
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() {
|
if !is_configured() {
|
||||||
return Err(MsaError::NotConfigured);
|
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 body: TokenResponse = response.json().map_err(MsaError::Network)?;
|
||||||
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
|
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>,
|
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 {
|
let request = XboxUserAuthRequest {
|
||||||
properties: XboxUserAuthProperties {
|
properties: XboxUserAuthProperties {
|
||||||
auth_method: "RPS",
|
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",
|
relying_party: "http://auth.xboxlive.com",
|
||||||
token_type: "JWT",
|
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() {
|
if !response.status().is_success() {
|
||||||
return Err(MsaError::HttpStatus(response.status()));
|
return Err(MsaError::HttpStatus(response.status()));
|
||||||
}
|
}
|
||||||
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
|
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))
|
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 {
|
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/",
|
relying_party: "rp://api.minecraftservices.com/",
|
||||||
token_type: "JWT",
|
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();
|
let status = response.status();
|
||||||
if status.as_u16() == 401 {
|
if status.as_u16() == 401 {
|
||||||
// XErr 2148916233 means the account has no Xbox profile at all
|
// 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));
|
return Err(MsaError::HttpStatus(status));
|
||||||
}
|
}
|
||||||
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
|
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))
|
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> {
|
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 request = MinecraftLoginRequest {
|
||||||
let response = client.post(MINECRAFT_LOGIN_URL).json(&request).send().map_err(MsaError::Network)?;
|
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() {
|
if !response.status().is_success() {
|
||||||
return Err(MsaError::HttpStatus(response.status()));
|
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
|
/// Confirms game ownership. A 404 here means the account has no Java
|
||||||
/// Edition profile — i.e. doesn't own the game — and nothing should
|
/// Edition profile — i.e. doesn't own the game — and nothing should
|
||||||
/// install or launch.
|
/// 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
|
let response = client
|
||||||
.get(MINECRAFT_PROFILE_URL)
|
.get(MINECRAFT_PROFILE_URL)
|
||||||
.bearer_auth(minecraft_access_token)
|
.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 (xsts_token, user_hash, xuid) = xsts_authorize(client, &xbox_live_token)?;
|
||||||
let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?;
|
let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?;
|
||||||
let profile = fetch_minecraft_profile(client, &minecraft_access_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)?;
|
let tokens = poll_device_code(client, start)?;
|
||||||
complete_login(client, tokens)
|
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)?;
|
let tokens = refresh_microsoft_tokens(client, refresh_token)?;
|
||||||
complete_login(client, tokens)
|
complete_login(client, tokens)
|
||||||
}
|
}
|
||||||
@@ -401,8 +474,15 @@ struct StoredAccount {
|
|||||||
|
|
||||||
pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> {
|
pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> {
|
||||||
fs::create_dir_all(data_dir)?;
|
fs::create_dir_all(data_dir)?;
|
||||||
let saved_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
let saved_at_unix = SystemTime::now()
|
||||||
let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable");
|
.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 target = data_dir.join(ACCOUNT_FILE);
|
||||||
let temporary = data_dir.join(".account.json.shacraft.part");
|
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;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
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> {
|
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()));
|
let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id()));
|
||||||
assert!(load_refresh_token(&dir).is_none());
|
assert!(load_refresh_token(&dir).is_none());
|
||||||
save_refresh_token(&dir, "super-secret-refresh-token").unwrap();
|
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();
|
clear_account(&dir).unwrap();
|
||||||
assert!(load_refresh_token(&dir).is_none());
|
assert!(load_refresh_token(&dir).is_none());
|
||||||
fs::remove_dir_all(&dir).ok();
|
fs::remove_dir_all(&dir).ok();
|
||||||
@@ -448,9 +531,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stored_account_file_is_not_world_or_group_readable() {
|
fn stored_account_file_is_not_world_or_group_readable() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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();
|
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);
|
assert_eq!(mode, 0o600);
|
||||||
fs::remove_dir_all(&dir).ok();
|
fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
@@ -459,7 +547,10 @@ mod tests {
|
|||||||
fn refuses_to_run_with_placeholder_client_id() {
|
fn refuses_to_run_with_placeholder_client_id() {
|
||||||
assert!(!is_configured());
|
assert!(!is_configured());
|
||||||
let client = Client::builder().build().unwrap();
|
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
|
/// Live smoke test: requests a real device code from Microsoft and
|
||||||
|
|||||||
+235
-39
@@ -57,21 +57,34 @@ pub enum NeoForgeError {
|
|||||||
Download(DownloadError),
|
Download(DownloadError),
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
InvalidJson(serde_json::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 {
|
impl fmt::Display for NeoForgeError {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
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::Network(error) => write!(formatter, "network error: {error}"),
|
||||||
Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"),
|
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::Download(error) => write!(formatter, "{error}"),
|
||||||
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
||||||
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
|
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
|
||||||
Self::InstallerFailed { exit_code, output_tail } => {
|
Self::InstallerFailed {
|
||||||
write!(formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}")
|
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 {
|
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 {
|
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,
|
/// Downloads (or reuses a cached, still-valid) NeoForge installer jar,
|
||||||
/// verified against the `.sha256` sidecar Maven publishes next to every
|
/// verified against the `.sha256` sidecar Maven publishes next to every
|
||||||
/// artifact.
|
/// 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 jar_url = installer_jar_url(loader_version);
|
||||||
let checksum_url = format!("{jar_url}.sha256");
|
let checksum_url = format!("{jar_url}.sha256");
|
||||||
if !is_allowed_host(&jar_url) {
|
if !is_allowed_host(&jar_url) {
|
||||||
return Err(NeoForgeError::DisallowedHost(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() {
|
if !response.status().is_success() {
|
||||||
return Err(NeoForgeError::HttpStatus(response.status()));
|
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()) {
|
if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
return Err(NeoForgeError::InvalidChecksum(sha256));
|
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"))
|
.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
|
/// The installer jar bundles its own `install_profile.json`, which lists
|
||||||
/// exactly which libraries it will download and which processors it will
|
/// exactly which libraries it will download and which processors it will
|
||||||
/// run to patch the client — the same manifest the installer itself reads.
|
/// 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
|
/// installer logging a couple of extra non-library downloads) never exceeds
|
||||||
/// or exceeds `total` by much. `total_libraries` caps the download half so
|
/// or exceeds `total` by much. `total_libraries` caps the download half so
|
||||||
/// those extra lines cannot crowd out the processor half of the bar.
|
/// 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();
|
let trimmed = line.trim_start();
|
||||||
if trimmed.starts_with("Download completed") {
|
if trimmed.starts_with("Download completed") {
|
||||||
downloads_done.fetch_add(1, Ordering::Relaxed);
|
downloads_done.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -173,12 +224,19 @@ fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_don
|
|||||||
} else {
|
} else {
|
||||||
return;
|
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);
|
on_progress(current.min(total), total);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate_tail(text: &str) -> String {
|
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
|
/// 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);
|
let on_progress = Arc::clone(on_progress);
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
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();
|
let mut log = combined_log.lock().unwrap();
|
||||||
log.push_str(&line);
|
log.push_str(&line);
|
||||||
log.push('\n');
|
log.push('\n');
|
||||||
@@ -243,7 +308,10 @@ fn run_installer_with_progress(
|
|||||||
let tail = truncate_tail(&combined_log.lock().unwrap());
|
let tail = truncate_tail(&combined_log.lock().unwrap());
|
||||||
|
|
||||||
if !status.success() {
|
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))
|
Ok((status.code(), tail))
|
||||||
}
|
}
|
||||||
@@ -258,19 +326,48 @@ fn run_installer_with_progress(
|
|||||||
/// progress (installer-confirmed library downloads plus patch-processor
|
/// progress (installer-confirmed library downloads plus patch-processor
|
||||||
/// steps, read from the installer's own `install_profile.json`) while it
|
/// steps, read from the installer's own `install_profile.json`) while it
|
||||||
/// runs; it fires once with `(1, 1)` when already installed.
|
/// 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);
|
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)?;
|
ensure_launcher_profiles_stub(game_dir)?;
|
||||||
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
|
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);
|
let total = (total_libraries + total_processors).max(1);
|
||||||
on_progress(0, total);
|
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)?;
|
let (exit_code, tail) = run_installer_with_progress(
|
||||||
if !version_json_path.exists() {
|
java_executable,
|
||||||
return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail });
|
&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);
|
on_progress(total, total);
|
||||||
} else {
|
} else {
|
||||||
@@ -296,12 +393,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_non_neoforge_hosts() {
|
fn rejects_non_neoforge_hosts() {
|
||||||
assert!(!is_allowed_host("https://example.com/evil.jar"));
|
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]
|
#[test]
|
||||||
fn launcher_profiles_stub_is_idempotent() {
|
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();
|
ensure_launcher_profiles_stub(&dir).unwrap();
|
||||||
let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap();
|
let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap();
|
||||||
fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap();
|
fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap();
|
||||||
@@ -312,6 +412,25 @@ mod tests {
|
|||||||
fs::remove_dir_all(&dir).unwrap();
|
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]
|
#[test]
|
||||||
fn observe_installer_line_counts_downloads_and_processor_headers() {
|
fn observe_installer_line_counts_downloads_and_processor_headers() {
|
||||||
let downloads_done = AtomicU64::new(0);
|
let downloads_done = AtomicU64::new(0);
|
||||||
@@ -326,12 +445,47 @@ mod tests {
|
|||||||
|
|
||||||
// A "Downloading library from ..." start line reports nothing by
|
// A "Downloading library from ..." start line reports nothing by
|
||||||
// itself; only its "Download completed" confirmation counts.
|
// 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(
|
||||||
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
"Downloading library from https://example/a.jar",
|
||||||
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
&downloads_done,
|
||||||
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
&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.
|
// 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)]);
|
assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]);
|
||||||
}
|
}
|
||||||
@@ -350,7 +504,8 @@ mod tests {
|
|||||||
use crate::{java, mojang};
|
use crate::{java, mojang};
|
||||||
|
|
||||||
let client = Client::builder().build().unwrap();
|
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 game_dir = root.join("game");
|
||||||
let cache_dir = root.join("cache");
|
let cache_dir = root.join("cache");
|
||||||
fs::create_dir_all(&cache_dir).unwrap();
|
fs::create_dir_all(&cache_dir).unwrap();
|
||||||
@@ -360,7 +515,8 @@ mod tests {
|
|||||||
let vanilla = mojang::fetch_version_json(&client, entry).unwrap();
|
let vanilla = mojang::fetch_version_json(&client, entry).unwrap();
|
||||||
|
|
||||||
let no_progress: ProgressCallback = Arc::new(|_, _| {});
|
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
|
// The installer fetches and patches vanilla itself; we don't
|
||||||
// pre-download it. It only needs a Java runtime and an empty dir.
|
// 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);
|
let progress_calls = Arc::clone(&progress_calls);
|
||||||
Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total)))
|
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();
|
let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap();
|
||||||
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
|
assert_eq!(
|
||||||
assert!(merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len());
|
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");
|
let patched_client =
|
||||||
assert!(patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath");
|
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();
|
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();
|
let (last_current, last_total) = *calls.last().unwrap();
|
||||||
assert_eq!(last_current, last_total, "progress must reach 100% on success");
|
assert_eq!(
|
||||||
assert!(calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards");
|
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);
|
drop(calls);
|
||||||
|
|
||||||
// Re-running must skip straight to reading the cached version JSON
|
// Re-running must skip straight to reading the cached version JSON
|
||||||
// rather than invoking the installer again.
|
// 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();
|
let neoforge_again = ensure_client_installed(
|
||||||
assert_eq!(neoforge_again.libraries.len(), neoforge_version.libraries.len());
|
&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();
|
fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-17
@@ -1,14 +1,17 @@
|
|||||||
use crate::manifest::{self, Manifest};
|
use crate::manifest::{self, Manifest};
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||||
|
use reqwest::header::ACCEPT_ENCODING;
|
||||||
use reqwest::{blocking::Client, redirect::Policy};
|
use reqwest::{blocking::Client, redirect::Policy};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{fmt, time::Duration};
|
use std::{fmt, thread, time::Duration};
|
||||||
|
|
||||||
const AERONAUTICS_MANIFEST: &str =
|
const AERONAUTICS_MANIFEST: &str =
|
||||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||||
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
||||||
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
||||||
|
const MAX_MANIFEST_SIZE: u64 = 2 * 1024 * 1024;
|
||||||
|
const MANIFEST_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -47,7 +50,9 @@ impl fmt::Display for RemoteError {
|
|||||||
Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"),
|
Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"),
|
||||||
Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"),
|
Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"),
|
||||||
Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"),
|
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())
|
.redirect(Policy::none())
|
||||||
.build()
|
.build()
|
||||||
.map_err(RemoteError::Network)?;
|
.map_err(RemoteError::Network)?;
|
||||||
let response = client.get(url).send().map_err(RemoteError::Network)?;
|
let mut last_network_error = None;
|
||||||
if !response.status().is_success() {
|
let mut source = None;
|
||||||
return Err(RemoteError::Status(response.status()));
|
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) {
|
let source = source.ok_or_else(|| {
|
||||||
return Err(RemoteError::TooLarge);
|
RemoteError::Network(last_network_error.expect("a network attempt failed"))
|
||||||
}
|
})?;
|
||||||
let source = response.text().map_err(RemoteError::Network)?;
|
let envelope = serde_json::from_slice::<SignedManifest>(&source)
|
||||||
let envelope = serde_json::from_str::<SignedManifest>(&source)
|
|
||||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||||
if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" {
|
if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" {
|
||||||
return Err(RemoteError::InvalidSignature);
|
return Err(RemoteError::InvalidSignature);
|
||||||
}
|
}
|
||||||
let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?;
|
let payload = STANDARD
|
||||||
let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?;
|
.decode(envelope.payload)
|
||||||
let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid");
|
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||||
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes"))
|
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");
|
.expect("embedded public key must be valid");
|
||||||
let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
|
let public_key = VerifyingKey::from_bytes(
|
||||||
public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?;
|
&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)?;
|
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||||
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)
|
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() {
|
if !response.status().is_success() {
|
||||||
return Err(RemoteError::Status(response.status()));
|
return Err(RemoteError::Status(response.status()));
|
||||||
}
|
}
|
||||||
response.json::<ServerStatus>().map_err(RemoteError::Network)
|
response
|
||||||
|
.json::<ServerStatus>()
|
||||||
|
.map_err(RemoteError::Network)
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-14
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::download;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{fmt, fs, io, path::Path};
|
use std::{fmt, fs, io, path::Path};
|
||||||
|
|
||||||
@@ -38,7 +39,11 @@ fn default_nickname() -> String {
|
|||||||
|
|
||||||
impl Default for LauncherSettings {
|
impl Default for LauncherSettings {
|
||||||
fn default() -> Self {
|
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 {
|
match self {
|
||||||
Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"),
|
Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"),
|
||||||
Self::InvalidJson(error) => write!(formatter, "Cannot read 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::InvalidMemory => {
|
||||||
Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"),
|
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 path = data_dir.join(SETTINGS_FILE);
|
||||||
let source = match fs::read_to_string(path) {
|
let source = match fs::read_to_string(path) {
|
||||||
Ok(source) => source,
|
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)),
|
Err(error) => return Err(SettingsError::Io(error)),
|
||||||
};
|
};
|
||||||
let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?;
|
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)
|
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)?;
|
validate(&settings)?;
|
||||||
fs::create_dir_all(data_dir).map_err(SettingsError::Io)?;
|
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 temporary = data_dir.join(".settings.json.shacraft.part");
|
||||||
let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
|
let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
|
||||||
fs::write(&temporary, contents).map_err(SettingsError::Io)?;
|
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)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
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);
|
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);
|
return Err(SettingsError::InvalidNickname);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -98,13 +120,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{load, save, AccountMode, LauncherSettings};
|
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 {
|
fn temporary_directory() -> std::path::PathBuf {
|
||||||
std::env::temp_dir().join(format!(
|
std::env::temp_dir().join(format!(
|
||||||
"shacraft-settings-test-{}-{}",
|
"shacraft-settings-test-{}-{}",
|
||||||
process::id(),
|
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.nickname, "Emil");
|
||||||
assert_eq!(default.account_mode, AccountMode::Offline);
|
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!(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();
|
fs::remove_dir_all(directory).unwrap();
|
||||||
}
|
}
|
||||||
@@ -126,8 +165,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_unsafe_memory_values() {
|
fn rejects_unsafe_memory_values() {
|
||||||
let directory = temporary_directory();
|
let directory = temporary_directory();
|
||||||
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err());
|
assert!(save(
|
||||||
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err());
|
&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]
|
#[test]
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ impl fmt::Display for AccountError {
|
|||||||
Self::Api(message) => formatter.write_str(message),
|
Self::Api(message) => formatter.write_str(message),
|
||||||
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
||||||
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
|
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 {
|
fn api_error(response: Response) -> AccountError {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ErrorBody { detail: Option<String> }
|
struct ErrorBody {
|
||||||
|
detail: Option<String>,
|
||||||
|
}
|
||||||
let status = response.status();
|
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}")))
|
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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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> {
|
fn load_session(data_dir: &Path) -> Result<String, AccountError> {
|
||||||
let token = fs::read_to_string(session_path(data_dir)).map_err(|error| {
|
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();
|
let token = token.trim();
|
||||||
if token.len() < 32 || token.bytes().any(|byte| byte.is_ascii_whitespace()) {
|
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())
|
Ok(token.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authenticate(data_dir: &Path, username: &str, password: &str, register: bool) -> Result<LoginResult, AccountError> {
|
pub fn authenticate(
|
||||||
let endpoint = if register { "/api/launcher/auth/register" } else { "/api/launcher/auth/login" };
|
data_dir: &Path,
|
||||||
let response = client()?.post(format!("{API_ORIGIN}{endpoint}"))
|
username: &str,
|
||||||
.json(&Credentials { username, password }).send().map_err(AccountError::Network)?;
|
password: &str,
|
||||||
if !response.status().is_success() { return Err(api_error(response)); }
|
register: bool,
|
||||||
let payload = response.json::<AuthResponse>().map_err(AccountError::Network)?;
|
) -> 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)?;
|
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> {
|
pub fn get_account(data_dir: &Path) -> Result<Account, AccountError> {
|
||||||
let token = load_session(data_dir)?;
|
let token = load_session(data_dir)?;
|
||||||
let response = client()?.get(format!("{API_ORIGIN}/api/launcher/account"))
|
let response = client()?
|
||||||
.bearer_auth(token).send().map_err(AccountError::Network)?;
|
.get(format!("{API_ORIGIN}/api/launcher/account"))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.map_err(AccountError::Network)?;
|
||||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
let _ = fs::remove_file(session_path(data_dir));
|
let _ = fs::remove_file(session_path(data_dir));
|
||||||
return Err(AccountError::InvalidSession);
|
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)
|
response.json::<Account>().map_err(AccountError::Network)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn logout(data_dir: &Path) -> Result<(), AccountError> {
|
pub fn logout(data_dir: &Path) -> Result<(), AccountError> {
|
||||||
if let Ok(token) = load_session(data_dir) {
|
if let Ok(token) = load_session(data_dir) {
|
||||||
let _ = client()?.post(format!("{API_ORIGIN}/api/launcher/auth/logout"))
|
let _ = client()?
|
||||||
.bearer_auth(token).json(&serde_json::json!({})).send();
|
.post(format!("{API_ORIGIN}/api/launcher/auth/logout"))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&serde_json::json!({}))
|
||||||
|
.send();
|
||||||
}
|
}
|
||||||
match fs::remove_file(session_path(data_dir)) {
|
match fs::remove_file(session_path(data_dir)) {
|
||||||
Ok(()) => Ok(()),
|
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 token = load_session(data_dir)?;
|
||||||
let response = client()?.post(format!("{API_ORIGIN}/api/account/link/start"))
|
let response = client()?
|
||||||
.bearer_auth(token).json(&serde_json::json!({"server_id": server_id, "mc_username": nickname}))
|
.post(format!("{API_ORIGIN}/api/account/link/start"))
|
||||||
.send().map_err(AccountError::Network)?;
|
.bearer_auth(token)
|
||||||
if !response.status().is_success() { return Err(api_error(response)); }
|
.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)
|
response.json::<LinkStart>().map_err(AccountError::Network)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
|
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
|
||||||
let token = load_session(data_dir)?;
|
let token = load_session(data_dir)?;
|
||||||
let response = client()?.get(format!("{API_ORIGIN}/api/account/link/status/{challenge_id}"))
|
let response = client()?
|
||||||
.bearer_auth(token).send().map_err(AccountError::Network)?;
|
.get(format!(
|
||||||
if !response.status().is_success() { return Err(api_error(response)); }
|
"{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)
|
response.json::<LinkStatus>().map_err(AccountError::Network)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
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")
|
.find(|link| link.server_id == "aoc")
|
||||||
.map(|link| link.mc_username)
|
.map(|link| link.mc_username)
|
||||||
.ok_or(AccountError::NoLinkedNickname)
|
.ok_or(AccountError::NoLinkedNickname)
|
||||||
@@ -182,13 +239,19 @@ pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{load_session, save_session, session_path};
|
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 {
|
fn temporary_directory() -> std::path::PathBuf {
|
||||||
std::env::temp_dir().join(format!(
|
std::env::temp_dir().join(format!(
|
||||||
"shacraft-account-test-{}-{}",
|
"shacraft-account-test-{}-{}",
|
||||||
process::id(),
|
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;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let directory = temporary_directory();
|
let directory = temporary_directory();
|
||||||
save_session(&directory, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG").unwrap();
|
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();
|
fs::remove_dir_all(directory).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user