Files
shacraft-launcher/src-tauri/src/mojang.rs
T

882 lines
29 KiB
Rust

//! Vanilla Minecraft trust boundary.
//!
//! Everything here talks only to Mojang's own public, unauthenticated CDN.
//! Which Minecraft version to install is decided entirely by the
//! ShaCraft-signed manifest (`manifest.rs`, `Manifest.minecraft`); this
//! module never takes a URL from that manifest. Every JSON document and
//! binary is verified against a SHA-1 obtained from an already-verified
//! parent document, all the way back to `version_manifest_v2.json`.
//!
//! The version-JSON types and `inheritsFrom` merge here follow the same
//! spec NeoForge's installer targets (see `neoforge.rs`), so a modloader
//! profile is handled by the identical, loader-agnostic merge algorithm
//! every vanilla-compatible third-party launcher uses.
use crate::download::{self, Checksum, DownloadError};
use reqwest::blocking::Client;
use serde::{de::DeserializeOwned, Deserialize};
use sha1::{Digest, Sha1};
use std::{
collections::HashMap,
fmt, fs, io,
path::{Path, PathBuf},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
const VERSION_MANIFEST_URL: &str =
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
const MOJANG_HOSTS: [&str; 4] = [
"piston-meta.mojang.com",
"piston-data.mojang.com",
"libraries.minecraft.net",
"resources.download.minecraft.net",
];
// Asset downloads are latency-bound (tens of thousands of small files), not
// bandwidth-bound, so throughput scales with concurrent in-flight requests
// far more than with per-connection speed; Mojang's CDN comfortably handles
// this many. Raised from an earlier, overly conservative 12.
const ASSET_WORKERS: usize = 48;
pub fn is_allowed_host(url: &str) -> bool {
crate::trusted_http::allows(url, &MOJANG_HOSTS)
}
pub fn http_client() -> Result<Client, reqwest::Error> {
crate::trusted_http::client(&MOJANG_HOSTS, std::time::Duration::from_secs(10 * 60))
}
/// The verified merged profile can contain both Mojang and NeoForge artifacts.
/// This broader client is used only for that library list, never metadata.
pub fn library_http_client() -> Result<Client, reqwest::Error> {
crate::trusted_http::client(
&[
MOJANG_HOSTS[0],
MOJANG_HOSTS[1],
MOJANG_HOSTS[2],
MOJANG_HOSTS[3],
crate::neoforge::NEOFORGE_HOST,
],
std::time::Duration::from_secs(10 * 60),
)
}
/// Library entries in a merged loader profile may point at the loader's
/// own fixed Maven. The profile itself comes from the SHA-256-verified
/// NeoForge installer, never from the ShaCraft manifest.
fn is_allowed_library_host(url: &str) -> bool {
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
}
#[derive(Debug)]
pub enum MojangError {
Network(reqwest::Error),
HttpStatus(reqwest::StatusCode),
InvalidJson(serde_json::Error),
ChecksumMismatch(String),
DisallowedHost(String),
MissingField(String),
ConflictingLibrary(String),
Download(DownloadError),
Io(io::Error),
}
impl fmt::Display for MojangError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"),
Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"),
Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"),
Self::DisallowedHost(url) => {
write!(formatter, "URL is not a recognised Mojang host: {url}")
}
Self::MissingField(field) => write!(formatter, "version JSON is missing {field}"),
Self::ConflictingLibrary(path) => write!(
formatter,
"merged version contains conflicting library entries for {path}"
),
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
}
}
}
impl From<DownloadError> for MojangError {
fn from(error: DownloadError) -> Self {
Self::Download(error)
}
}
impl From<io::Error> for MojangError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
// ---------------------------------------------------------------------
// Version manifest
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct VersionManifest {
pub versions: Vec<VersionManifestEntry>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct VersionManifestEntry {
pub id: String,
pub url: String,
pub sha1: String,
}
pub fn fetch_version_manifest(client: &Client) -> Result<VersionManifest, MojangError> {
fetch_json(client, VERSION_MANIFEST_URL, None)
}
pub fn find_version<'a>(
manifest: &'a VersionManifest,
id: &str,
) -> Option<&'a VersionManifestEntry> {
manifest.versions.iter().find(|entry| entry.id == id)
}
// ---------------------------------------------------------------------
// Version JSON (shared shape with NeoForge's installed profile JSON)
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct VersionJson {
pub id: String,
pub main_class: String,
#[serde(default)]
pub arguments: Option<Arguments>,
#[serde(default)]
pub asset_index: Option<AssetIndexRef>,
#[serde(default)]
pub downloads: Option<Downloads>,
#[serde(default)]
pub libraries: Vec<Library>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct Arguments {
#[serde(default)]
pub game: Vec<ArgumentValue>,
#[serde(default)]
pub jvm: Vec<ArgumentValue>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum ArgumentValue {
Plain(String),
Conditional {
rules: Vec<Rule>,
value: StringOrList,
},
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum StringOrList {
One(String),
Many(Vec<String>),
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AssetIndexRef {
pub id: String,
pub sha1: String,
pub size: u64,
pub url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Downloads {
pub client: DownloadRef,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DownloadRef {
pub sha1: String,
pub size: u64,
pub url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Library {
pub name: String,
#[serde(default)]
pub downloads: Option<LibraryDownloads>,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct LibraryDownloads {
pub artifact: Option<Artifact>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Artifact {
pub path: String,
pub url: String,
pub sha1: String,
pub size: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Rule {
pub action: RuleAction,
#[serde(default)]
pub os: Option<RuleOs>,
#[serde(default)]
pub features: Option<HashMap<String, bool>>,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RuleAction {
Allow,
Disallow,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct RuleOs {
pub name: Option<String>,
pub arch: Option<String>,
}
fn current_os_name() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"osx"
} else {
"linux"
}
}
fn arch_matches(expected: &str) -> bool {
let normalized = if expected == "arm64" {
"aarch64"
} else {
expected
};
normalized == std::env::consts::ARCH
}
fn os_matches(os: &RuleOs) -> bool {
if let Some(name) = &os.name {
if name != current_os_name() {
return false;
}
}
if let Some(arch) = &os.arch {
if !arch_matches(arch) {
return false;
}
}
true
}
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)
}
/// Evaluates a Mojang-style rule list: no rules means always allowed;
/// otherwise the last matching rule (in order) decides, defaulting to
/// disallowed if nothing matched. `active_features` should list only the
/// optional launch features actually supported (today: none — no demo
/// mode, no custom resolution, no quick-play).
pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> bool {
if rules.is_empty() {
return true;
}
let mut allowed = false;
for rule in rules {
let os_ok = rule.os.as_ref().is_none_or(os_matches);
let features_ok = rule
.features
.as_ref()
.is_none_or(|required| features_match(required, active_features));
if os_ok && features_ok {
allowed = rule.action == RuleAction::Allow;
}
}
allowed
}
/// Flattens an argument list into plain strings, dropping conditional
/// entries whose rules don't match this platform/feature set.
pub fn resolve_arguments(
arguments: &[ArgumentValue],
active_features: &HashMap<String, bool>,
) -> Vec<String> {
let mut resolved = Vec::new();
for argument in arguments {
match argument {
ArgumentValue::Plain(value) => resolved.push(value.clone()),
ArgumentValue::Conditional { rules, value } => {
if rule_allows(rules, active_features) {
match value {
StringOrList::One(value) => resolved.push(value.clone()),
StringOrList::Many(values) => resolved.extend(values.iter().cloned()),
}
}
}
}
}
resolved
}
pub fn fetch_version_json(
client: &Client,
entry: &VersionManifestEntry,
) -> Result<VersionJson, MojangError> {
fetch_json(client, &entry.url, Some(&entry.sha1))
}
fn fetch_json<T: DeserializeOwned>(
client: &Client,
url: &str,
expected_sha1: Option<&str>,
) -> Result<T, MojangError> {
if !is_allowed_host(url) {
return Err(MojangError::DisallowedHost(url.to_string()));
}
let response = client.get(url).send().map_err(MojangError::Network)?;
if !response.status().is_success() {
return Err(MojangError::HttpStatus(response.status()));
}
let bytes = response.bytes().map_err(MojangError::Network)?;
if let Some(expected) = expected_sha1 {
let mut hasher = Sha1::new();
hasher.update(&bytes);
let actual = format!("{:x}", hasher.finalize());
if !actual.eq_ignore_ascii_case(expected) {
return Err(MojangError::ChecksumMismatch(url.to_string()));
}
}
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
}
// ---------------------------------------------------------------------
// inheritsFrom merge
// ---------------------------------------------------------------------
pub struct MergedVersion {
/// The version being launched: the child's id when there is a loader
/// (e.g. `neoforge-21.1.248`), otherwise the parent's. Used for
/// `${version_name}` and as the name of the `versions/<id>/` directory
/// logs and natives live under.
pub id: String,
/// The id whose `<id>.jar` actually exists on disk and belongs on the
/// classpath — always the vanilla parent's, confirmed empirically: a
/// NeoForge profile has no `versions/neoforge-<ver>/neoforge-<ver>.jar`
/// of its own (FancyModLoader loads the patched client itself, see
/// `neoforge.rs`), so callers must use this id, not `id` above, to find
/// the client jar (`client_jar_path`).
pub client_jar_version_id: String,
pub main_class: String,
pub game_arguments: Vec<ArgumentValue>,
pub jvm_arguments: Vec<ArgumentValue>,
pub libraries: Vec<Library>,
pub asset_index: AssetIndexRef,
pub client: DownloadRef,
}
/// Merges a child version JSON (e.g. NeoForge's) onto its vanilla parent
/// the same way the official Minecraft Launcher merges any `inheritsFrom`
/// profile: the child's `mainClass` wins, its arguments are appended after
/// the parent's, and its libraries are appended after the parent's.
/// `assetIndex`/`downloads.client` always come from the parent, since
/// modloader profiles don't redeclare them.
pub fn merge_versions(
parent: &VersionJson,
child: Option<&VersionJson>,
) -> Result<MergedVersion, MojangError> {
let asset_index = parent
.asset_index
.clone()
.ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
let client = parent
.downloads
.as_ref()
.map(|downloads| downloads.client.clone())
.ok_or_else(|| MojangError::MissingField("downloads.client".into()))?;
let parent_arguments = parent.arguments.clone().unwrap_or_default();
let Some(child) = child else {
return Ok(MergedVersion {
id: parent.id.clone(),
client_jar_version_id: parent.id.clone(),
main_class: parent.main_class.clone(),
game_arguments: parent_arguments.game,
jvm_arguments: parent_arguments.jvm,
libraries: parent.libraries.clone(),
asset_index,
client,
});
};
let child_arguments = child.arguments.clone().unwrap_or_default();
let mut game_arguments = parent_arguments.game;
game_arguments.extend(child_arguments.game);
let mut jvm_arguments = parent_arguments.jvm;
jvm_arguments.extend(child_arguments.jvm);
let mut libraries = parent.libraries.clone();
libraries.extend(child.libraries.clone());
Ok(MergedVersion {
id: child.id.clone(),
client_jar_version_id: parent.id.clone(),
main_class: child.main_class.clone(),
game_arguments,
jvm_arguments,
libraries,
asset_index,
client,
})
}
// ---------------------------------------------------------------------
// Downloading
// ---------------------------------------------------------------------
pub use crate::download::ProgressCallback;
pub fn natives_directory(game_dir: &Path, version_id: &str) -> PathBuf {
game_dir.join("versions").join(version_id).join("natives")
}
pub fn client_jar_path(game_dir: &Path, version_id: &str) -> PathBuf {
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> {
if !is_allowed_host(&download_ref.url) {
return Err(MojangError::DisallowedHost(download_ref.url.clone()));
}
let target = client_jar_path(game_dir, version_id);
let checksum = Checksum::Sha1(download_ref.sha1.clone());
if !download::is_current(&target, Some(download_ref.size), &checksum)? {
download::download_verified(
client,
&download_ref.url,
&target,
Some(download_ref.size),
&checksum,
|_, _| {},
)?;
}
Ok(target)
}
/// Downloads every rule-allowed library with a `downloads.artifact`,
/// returning the resulting jar paths in the same order as `libraries`.
pub fn ensure_libraries(
client: &Client,
game_dir: &Path,
libraries: &[Library],
on_progress: &ProgressCallback,
) -> Result<Vec<PathBuf>, MojangError> {
let mut paths = Vec::new();
let mut tasks = Vec::new();
let mut seen: HashMap<PathBuf, (String, u64, String)> = HashMap::new();
for library in libraries {
if !rule_allows(&library.rules, &HashMap::new()) {
continue;
}
let Some(artifact) = library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
else {
continue;
};
let target = game_dir.join("libraries").join(&artifact.path);
let identity = (artifact.url.clone(), artifact.size, artifact.sha1.clone());
if let Some(existing) = seen.get(&target) {
if existing != &identity {
return Err(MojangError::ConflictingLibrary(
target.display().to_string(),
));
}
continue;
}
seen.insert(target.clone(), identity);
paths.push(target.clone());
tasks.push(DownloadTask {
url: artifact.url.clone(),
target,
size: artifact.size,
checksum: Checksum::Sha1(artifact.sha1.clone()),
});
}
download_many(client, tasks, on_progress, is_allowed_library_host)?;
Ok(paths)
}
#[derive(Debug, Deserialize)]
pub struct AssetIndex {
pub objects: HashMap<String, AssetObject>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AssetObject {
pub hash: String,
pub size: u64,
}
pub fn ensure_asset_index(
client: &Client,
game_dir: &Path,
asset_index: &AssetIndexRef,
) -> Result<AssetIndex, MojangError> {
if !is_allowed_host(&asset_index.url) {
return Err(MojangError::DisallowedHost(asset_index.url.clone()));
}
let target = game_dir
.join("assets")
.join("indexes")
.join(format!("{}.json", asset_index.id));
let checksum = Checksum::Sha1(asset_index.sha1.clone());
if !download::is_current(&target, Some(asset_index.size), &checksum)? {
download::download_verified(
client,
&asset_index.url,
&target,
Some(asset_index.size),
&checksum,
|_, _| {},
)?;
}
let bytes = fs::read(&target)?;
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
}
pub fn ensure_assets(
client: &Client,
game_dir: &Path,
index: &AssetIndex,
on_progress: &ProgressCallback,
) -> Result<(), MojangError> {
let objects_dir = game_dir.join("assets").join("objects");
let tasks = index
.objects
.values()
.map(|object| {
let prefix = &object.hash[0..2];
DownloadTask {
url: format!(
"https://resources.download.minecraft.net/{prefix}/{}",
object.hash
),
target: objects_dir.join(prefix).join(&object.hash),
size: object.size,
checksum: Checksum::Sha1(object.hash.clone()),
}
})
.collect();
download_many(client, tasks, on_progress, is_allowed_host)
}
struct DownloadTask {
url: String,
target: PathBuf,
size: u64,
checksum: Checksum,
}
const MAX_DOWNLOAD_ATTEMPTS: u32 = 5;
/// Retries a single file a few times with a short backoff before giving up.
/// With tens of thousands of individual requests in `ensure_assets`, an
/// occasional transient failure (reset connection, one bad TLS record) is
/// expected network noise, not a reason to abort the whole install — this
/// is exactly what real launchers do at this scale.
fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, DownloadError> {
let mut last_error = None;
for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS {
match download::download_verified(
client,
&task.url,
&task.target,
Some(task.size),
&task.checksum,
|_, _| {},
) {
Ok(bytes) => return Ok(bytes),
Err(error) => {
last_error = Some(error);
if attempt < MAX_DOWNLOAD_ATTEMPTS {
std::thread::sleep(std::time::Duration::from_millis(200 * attempt as u64));
}
}
}
}
Err(last_error.expect("loop runs at least once"))
}
/// Downloads `tasks` using a small worker pool, calling `on_progress` with
/// cumulative (downloaded, total) bytes as each file completes. Stops
/// spawning new work once the first error is seen and returns it.
fn download_many(
client: &Client,
tasks: Vec<DownloadTask>,
on_progress: &ProgressCallback,
is_allowed: fn(&str) -> bool,
) -> Result<(), MojangError> {
let total: u64 = tasks.iter().map(|task| task.size).sum();
if total == 0 {
return Ok(());
}
let downloaded = AtomicU64::new(0);
let queue = Mutex::new(tasks);
let first_error: Mutex<Option<MojangError>> = Mutex::new(None);
std::thread::scope(|scope| {
for _ in 0..ASSET_WORKERS {
let queue = &queue;
let downloaded = &downloaded;
let first_error = &first_error;
let on_progress = Arc::clone(on_progress);
scope.spawn(move || loop {
if first_error.lock().unwrap().is_some() {
break;
}
let Some(task) = queue.lock().unwrap().pop() else {
break;
};
if !is_allowed(&task.url) {
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
continue;
}
let already_current =
download::is_current(&task.target, Some(task.size), &task.checksum)
.unwrap_or(false);
if !already_current {
if let Err(error) = download_with_retries(client, &task) {
*first_error.lock().unwrap() = Some(MojangError::Download(error));
continue;
}
}
let done = downloaded.fetch_add(task.size, Ordering::SeqCst) + task.size;
on_progress(done, total);
});
}
});
match first_error.into_inner().unwrap() {
Some(error) => Err(error),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(action: RuleAction, os_name: Option<&str>) -> Rule {
Rule {
action,
os: os_name.map(|name| RuleOs {
name: Some(name.into()),
arch: None,
}),
features: None,
}
}
#[test]
fn empty_rules_always_allow() {
assert!(rule_allows(&[], &HashMap::new()));
}
#[test]
fn single_matching_os_rule_allows() {
let rules = vec![rule(RuleAction::Allow, Some(current_os_name()))];
assert!(rule_allows(&rules, &HashMap::new()));
}
#[test]
fn non_matching_os_rule_disallows() {
let other = if current_os_name() == "windows" {
"linux"
} else {
"windows"
};
let rules = vec![rule(RuleAction::Allow, Some(other))];
assert!(!rule_allows(&rules, &HashMap::new()));
}
#[test]
fn unsupported_feature_is_excluded_by_default() {
let mut features = HashMap::new();
features.insert("is_demo_user".to_string(), true);
let rules = vec![Rule {
action: RuleAction::Allow,
os: None,
features: Some(features),
}];
// We never activate optional features, so a rule requiring one
// must not match even though there's no OS constraint.
assert!(!rule_allows(&rules, &HashMap::new()));
}
#[test]
fn resolves_plain_and_conditional_arguments() {
let args = vec![
ArgumentValue::Plain("--username".into()),
ArgumentValue::Plain("${auth_player_name}".into()),
ArgumentValue::Conditional {
rules: vec![rule(RuleAction::Allow, Some(current_os_name()))],
value: StringOrList::Many(vec!["--this-os-only".into()]),
},
ArgumentValue::Conditional {
rules: vec![rule(RuleAction::Allow, Some("nonexistent-os"))],
value: StringOrList::One("--never".into()),
},
];
let resolved = resolve_arguments(&args, &HashMap::new());
assert_eq!(
resolved,
vec!["--username", "${auth_player_name}", "--this-os-only"]
);
}
#[test]
fn merge_appends_child_after_parent() {
let parent: VersionJson = serde_json::from_str(
r#"{
"id": "1.21.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {"game": ["--parentGame"], "jvm": ["--parentJvm"]},
"assetIndex": {"id": "17", "sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1, "url": "https://piston-meta.mojang.com/x"},
"downloads": {"client": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 2, "url": "https://piston-data.mojang.com/x"}},
"libraries": [{"name": "parent:lib:1"}]
}"#,
)
.unwrap();
let child: VersionJson = serde_json::from_str(
r#"{
"id": "neoforge-21.1.248",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"inheritsFrom": "1.21.1",
"arguments": {"game": ["--childGame"], "jvm": ["--childJvm"]},
"libraries": [{"name": "child:lib:1"}]
}"#,
)
.unwrap();
let merged = merge_versions(&parent, Some(&child)).unwrap();
assert_eq!(merged.id, "neoforge-21.1.248");
assert_eq!(merged.client_jar_version_id, "1.21.1");
assert_eq!(
merged.main_class,
"cpw.mods.bootstraplauncher.BootstrapLauncher"
);
assert_eq!(
resolve_arguments(&merged.game_arguments, &HashMap::new()),
vec!["--parentGame", "--childGame"]
);
assert_eq!(
resolve_arguments(&merged.jvm_arguments, &HashMap::new()),
vec!["--parentJvm", "--childJvm"]
);
assert_eq!(
merged
.libraries
.iter()
.map(|library| library.name.as_str())
.collect::<Vec<_>>(),
vec!["parent:lib:1", "child:lib:1"]
);
assert_eq!(merged.asset_index.id, "17");
}
#[test]
fn disallowed_host_is_rejected() {
assert!(!is_allowed_host("https://example.com/evil.jar"));
assert!(is_allowed_host(
"https://piston-data.mojang.com/v1/objects/x/client.jar"
));
assert!(is_allowed_library_host(
"https://maven.neoforged.net/releases/net/neoforged/example.jar"
));
assert!(!is_allowed_library_host("https://example.com/evil.jar"));
}
/// Live smoke test against the real Mojang CDN: manifest -> version JSON
/// (SHA-1 verified) -> asset index -> a handful of libraries + the
/// client jar. Not run by default (`cargo test`); run explicitly with
/// `cargo test -- --ignored mojang::` when checking real connectivity.
#[test]
#[ignore]
fn live_fetches_1_21_1_and_downloads_a_few_files() {
let client = Client::builder().build().unwrap();
let manifest = fetch_version_manifest(&client).unwrap();
let entry = find_version(&manifest, "1.21.1").expect("1.21.1 must be listed");
let version = fetch_version_json(&client, entry).unwrap();
assert_eq!(version.main_class, "net.minecraft.client.main.Main");
let game_dir =
std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
let merged = merge_versions(&version, None).unwrap();
let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
assert!(client_jar.exists());
let asset_index = ensure_asset_index(&client, &game_dir, &merged.asset_index).unwrap();
assert!(!asset_index.objects.is_empty());
let mut small_libraries: Vec<Library> = merged
.libraries
.iter()
.filter(|library| {
library
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.is_some_and(|artifact| artifact.size < 200_000)
})
.take(5)
.cloned()
.collect();
assert!(
!small_libraries.is_empty(),
"expected at least one small library to sanity-check downloads with"
);
small_libraries.truncate(5);
let progress: ProgressCallback = Arc::new(|_, _| {});
let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap();
for path in &paths {
assert!(path.exists(), "{path:?} should have been downloaded");
}
// Re-running against already-downloaded files must be a no-op (the
// `is_current` fast path), not re-download or fail.
let client_jar_again =
ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
assert_eq!(client_jar, client_jar_again);
fs::remove_dir_all(&game_dir).ok();
}
}