feat: auto-repair as well as verify cluster button in cluster settings

This commit is contained in:
LynithDev 2026-08-01 23:22:20 +02:00
parent 4c6de0d2c4
commit 394146d552
35 changed files with 3167 additions and 113 deletions

1
Cargo.lock generated
View file

@ -5109,6 +5109,7 @@ dependencies = [
"serde",
"serde_json",
"sha1 0.10.6",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
"tokio-util",

View file

@ -0,0 +1,198 @@
//! The fallback renderer for prompts nothing else claims.
//!
//! [`Prompt`](oneclient_events::Prompt) lets any part of the core ask the user a
//! question, and two overlays render particular ones richly: the Java one and
//! the updater's. Everything else used to render as nothing at all — the core
//! would sit on `ask()` forever waiting for a reply no one could give, and
//! because the app holds a single pending prompt at a time, the stuck one also
//! kept every later prompt (the Java one included) from appearing.
//!
//! So this is a safety net, not a style: anything not claimed above is rendered
//! from the prompt's own choices, and a new prompt in the core needs no UI work
//! to be answerable.
use freya::prelude::*;
use oneclient_events::{Answer, ChoiceInput, ChoiceStyle};
use oneclient_java::JAVA_CHOICE_DOWNLOAD;
use crate::components::{Button, OverlayPopup};
use crate::hooks::{use_dispatch, use_notifications_snapshot};
use crate::notifications::PendingPromptView;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::updater::UPDATE_CHOICE_INSTALL;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
/// Prompts with an overlay of their own, recognised the same way those overlays
/// recognise themselves: by the choice they offer.
fn claimed_elsewhere(prompt: &PendingPromptView) -> bool {
prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL)
}
#[derive(PartialEq)]
pub struct GenericPromptOverlay;
impl Component for GenericPromptOverlay {
fn render(&self) -> impl IntoElement {
// Hooks run unconditionally, before any early return, so the hook count
// stays stable whether or not a prompt is pending.
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
let Some(prompt) = snapshot.pending_prompt.clone() else {
return rect().into_element();
};
if claimed_elsewhere(&prompt) {
return rect().into_element();
}
let close = dispatch.clone();
let mut buttons = rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.);
if let Some(dismiss) = prompt.dismiss.clone() {
let cancel = dispatch.clone();
buttons = buttons.child(
Button::new()
.secondary()
.text(dismiss)
.on_press(move |_| cancel.dismiss_prompt()),
);
}
for choice in &prompt.choices {
let dispatch = dispatch.clone();
let id = choice.id;
let input = choice.input.clone();
let button = Button::new().text(choice.label.clone());
let button = match choice.style {
ChoiceStyle::Primary => button.primary(),
ChoiceStyle::Danger => button.danger(),
ChoiceStyle::Secondary => button.secondary(),
};
buttons = buttons.child(button.on_press(move |_| match &input {
// A folder choice cannot be answered until the user has picked
// one, so the answer is sent from the dialog's callback rather
// than from the press.
Some(ChoiceInput::Folder { title }) => {
let dispatch = dispatch.clone();
let title = title.clone();
spawn(async move {
if let Some(handle) = rfd::AsyncFileDialog::new()
.set_title(title)
.pick_folder()
.await
{
dispatch.answer_prompt(
Answer::new(id).with_folder(handle.path()),
);
}
});
}
// A selection needs a picker only the prompt's own overlay could
// supply. Answering without one is still better than leaving the
// core waiting forever; the caller sees `selection() == None`.
Some(ChoiceInput::Selection { hint }) => {
tracing::warn!(
choice = id,
hint,
"a prompt asked for a selection with no overlay to collect it"
);
dispatch.answer_prompt(Answer::new(id));
}
None => dispatch.answer_prompt(Answer::new(id)),
}));
}
OverlayPopup::new()
.on_close(move |_| close.dismiss_prompt())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(440.))
.max_width(Size::window_percent(90.))
.spacing(14.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(14.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text(prompt.title.clone())
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(prompt.question.clone())
.font_size(12.)
.max_lines(6)
.width(Size::fill())
.color(colors::fg_secondary()),
)
.child(buttons),
),
)
.into_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use oneclient_events::Choice;
fn prompt(choices: Vec<Choice>) -> PendingPromptView {
PendingPromptView {
title: "Title".to_string(),
question: "Question".to_string(),
choices,
dismiss: Some("Cancel".to_string()),
}
}
#[test]
fn the_two_rich_overlays_keep_their_own_prompts() {
assert!(claimed_elsewhere(&prompt(vec![Choice::primary(
JAVA_CHOICE_DOWNLOAD,
"Download",
)])));
assert!(claimed_elsewhere(&prompt(vec![Choice::primary(
UPDATE_CHOICE_INSTALL,
"Download",
)])));
}
#[test]
fn every_other_prompt_falls_through_to_the_generic_overlay() {
// The bug this overlay exists for: a prompt whose choices no overlay
// recognised rendered as nothing, the core waited on `ask()` forever,
// and the stuck prompt blocked every later one. Any new prompt in the
// core has to land here without UI work.
for choices in [
vec![Choice::new("launch", "Launch anyway")],
vec![Choice::primary("verify", "Verify and repair")],
vec![Choice::danger("delete", "Delete")],
Vec::new(),
] {
assert!(
!claimed_elsewhere(&prompt(choices)),
"an unclaimed prompt must render somewhere"
);
}
}
}

View file

@ -11,6 +11,7 @@ mod dynamic_art;
mod file_drop;
mod icons;
mod java_install_manager;
mod generic_prompt;
mod java_prompt;
mod link_confirm;
mod local_image;
@ -49,6 +50,7 @@ pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use file_drop::FileDropOverlay;
pub use icons::{Icon, IconType};
pub use java_install_manager::JavaInstallManager;
pub use generic_prompt::GenericPromptOverlay;
pub use java_prompt::JavaPromptOverlay;
pub use link_confirm::ConfirmLinkOverlay;
pub use local_image::LocalImage;

View file

@ -1333,6 +1333,63 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
.await;
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
// A launch that failed because a file simply is not there is the one
// failure the launcher can fix by itself. Showing the user a path inside
// our own metadata folder and stopping leaves them with nothing to act
// on, so repair first and only report if that does not help.
if err.indicates_missing_files() {
repair_and_relaunch(&state, cluster_id, &account, err).await;
return;
}
events.game_failed(cluster_id, format!("{err:#}"));
}
}
/// Runs a verify-and-repair pass, then tries the launch once more.
///
/// Only ever one retry: if the game still cannot start after every file has
/// been hashed and the broken ones re-downloaded, the problem is not missing
/// files, and trying again would just spend another few minutes to show the
/// same error.
async fn repair_and_relaunch(
state: &std::sync::Arc<oneclient_core::LauncherState>,
cluster_id: ClusterId,
account: &oneclient_auth::MinecraftAccount,
original: oneclient_core::LauncherError,
) {
let events = state.services.events.clone();
tracing::warn!(
cluster_id,
"launch failed on a missing file; repairing: {original:#}"
);
events
.notify("Repairing installation")
.body("Some game files are missing. Checking and re-downloading them.")
.send();
let report = match oneclient_core::verify_cluster_files(state, cluster_id).await {
Ok(report) => report,
Err(repair_err) => {
tracing::error!(cluster_id, "repair failed: {repair_err:#}");
// The original failure is what the user was trying to do, so lead
// with it; the repair failure explains why it was not fixed.
events.game_failed(
cluster_id,
format!("{original:#} (repair also failed: {repair_err:#})"),
);
return;
}
};
events
.notify("Repair complete")
.body(report.summary())
.send();
if let Err(err) = oneclient_core::launch_cluster(state, cluster_id, account, true).await {
tracing::error!(cluster_id, "launch failed again after repair: {err:#}");
events.game_failed(cluster_id, format!("{err:#}"));
}
}

View file

@ -78,6 +78,10 @@ pub enum ClusterAction {
cluster_id: ClusterId,
dedicated: bool,
},
/// Rehash every installed file and re-download whatever no longer matches.
VerifyFiles {
cluster_id: ClusterId,
},
}
impl MutationCapability for ClusterMutation {
@ -155,6 +159,26 @@ impl MutationCapability for ClusterMutation {
reason: err.to_string(),
})
}
ClusterAction::VerifyFiles { cluster_id } => {
// Reports its own outcome rather than going through the generic
// failure toast: a verify that finds nothing wrong is a useful
// result the user asked for, not a silent no-op.
match oneclient_core::verify_cluster_files(&state, *cluster_id).await {
Ok(report) => {
let notify = services.events.notify("Verification complete");
let notify = notify.body(report.summary());
if report.unrepairable.is_empty() {
notify.send();
} else {
notify.error().send();
}
Ok(())
}
Err(err) => Err(oneclient_content::ContentError::InvalidData {
reason: err.to_string(),
}),
}
}
};
result.map_err(|e| e.to_string())
}

View file

@ -2,8 +2,9 @@ use freya::prelude::*;
use freya::router::*;
use crate::components::{
AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, PackageUpdatePopup,
SplashCurtain, StatusBar, Toasts, UpdatePromptOverlay,
AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay,
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts,
UpdatePromptOverlay,
};
use crate::hooks::{SplashState, use_provide_splash};
use crate::layout::HomeArtPrefetch;
@ -71,6 +72,9 @@ impl Component for RootLayout {
.child(Toasts)
.child(UpdatePromptOverlay)
.child(JavaPromptOverlay)
// Last of the prompt overlays: it renders whatever the two above
// did not claim, so no prompt can reach the user as silence.
.child(GenericPromptOverlay)
.child(ClusterUpdatePopup)
.child(PackageUpdatePopup)
.child(StatusBar)

View file

@ -11,8 +11,9 @@ use crate::components::{
validate_number,
};
use crate::hooks::{
ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation,
use_dispatch, use_game_profile, use_java_runtimes, use_loader_versions, use_settings_snapshot,
ClusterAction, java_runtimes, loader_versions, mutation_is_running, try_game_profile,
use_cluster_mutation, use_dispatch, use_game_profile, use_java_runtimes, use_loader_versions,
use_settings_snapshot,
};
use crate::layout::cluster_content;
use crate::theme::colors;
@ -127,7 +128,9 @@ impl Component for ClusterSettings {
.child(section_header("PROCESS"))
.child(text_row(cluster_id, TextField::Pre, &profile, &global))
.child(text_row(cluster_id, TextField::Wrapper, &profile, &global))
.child(text_row(cluster_id, TextField::Post, &profile, &global)),
.child(text_row(cluster_id, TextField::Post, &profile, &global))
.child(section_header("REPAIR"))
.child(VerifyFilesRow { cluster_id }.into_element()),
)
.into_element()
}
@ -241,6 +244,41 @@ impl Component for ToggleRow {
}
}
#[derive(PartialEq)]
struct VerifyFilesRow {
cluster_id: i64,
}
impl Component for VerifyFilesRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let mutation = use_cluster_mutation();
let running = mutation_is_running(&mutation);
let on_press = move |_| {
mutation.mutate(ClusterAction::VerifyFiles { cluster_id });
};
// The pass reads every installed file, so it is worth saying that it
// takes a while — otherwise a button that sits there for a minute reads
// as broken rather than busy.
let button = Button::new()
.small()
.secondary()
.enabled(!running)
.maybe(!running, |el| el.on_press(on_press))
.text(if running { "Verifying..." } else { "Verify Files" });
settings_row(
IconType::ClipboardCheck,
"Verify Files",
"Check every installed file against its official checksum and \
re-download anything corrupt or missing. Takes a few minutes.",
button,
)
}
}
#[derive(PartialEq)]
struct DedicatedDirRow {
cluster_id: i64,

View file

@ -3,6 +3,7 @@ use freya::router::RouterContext;
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
use oneclient_auth::preview_samples;
use oneclient_core::simulate::Damage;
use oneclient_net::status::{self, ServiceStatus};
use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle};
@ -87,6 +88,11 @@ impl Component for Debug {
vec![AuthGuidancePreview.into_element()],
))
.child(divider())
.child(section(
"Corruption Simulator",
vec![CorruptionSimulator.into_element()],
))
.child(divider())
.child(section("SQL Console", vec![SqlConsole.into_element()]))
.child(divider())
.child(section(
@ -655,6 +661,297 @@ impl Component for AuthGuidancePreview {
}
}
/// Breaks an installation on purpose, so the repair paths can be driven without
/// waiting for a genuinely bad connection.
///
/// The presets are grouped by *which layer is supposed to catch them*, because
/// that is the thing worth testing: same-length corruption is invisible to the
/// launch-time size check and only the hashing pass finds it, truncation is
/// caught cheaply on the next launch, and a deleted assets tree is the case that
/// used to surface as a raw "No such file or directory".
#[derive(PartialEq)]
struct CorruptionSimulator;
/// `(label, icon, what it does, which check is meant to catch it)`
type DamagePreset = (&'static str, IconType, DamageKind, &'static str);
#[derive(Clone, Copy, PartialEq, Eq)]
enum DamageKind {
Assets(usize, Damage),
Libraries(usize, Damage),
Content(usize, Damage),
DeleteAssetsTree,
}
const DAMAGE_PRESETS: [DamagePreset; 6] = [
(
"Corrupt 25 assets",
IconType::AlertTriangle,
DamageKind::Assets(25, Damage::Corrupt),
"Survives launch; only Verify Files finds it",
),
(
"Truncate 25 assets",
IconType::FileX02,
DamageKind::Assets(25, Damage::Truncate),
"Caught at launch by the size check",
),
(
"Delete 25 assets",
IconType::Trash01,
DamageKind::Assets(25, Damage::Delete),
"Caught at launch, re-downloaded",
),
(
"Corrupt 5 libraries",
IconType::AlertCircle,
DamageKind::Libraries(5, Damage::Corrupt),
"Crashes with ZipException, then offers repair",
),
(
"Corrupt 5 mods",
IconType::File02,
DamageKind::Content(5, Damage::Corrupt),
"Repaired from the provider, not Mojang",
),
(
"Delete the assets tree",
IconType::Folder,
DamageKind::DeleteAssetsTree,
"The reported bug; auto-repairs on launch",
),
];
impl Component for CorruptionSimulator {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let cluster_id = use_state(|| "1".to_string());
let mut rows = Vec::new();
for chunk in DAMAGE_PRESETS.chunks(3) {
let mut row = rect().horizontal().width(Size::fill()).spacing(8.);
for (text, icon, kind, hint) in chunk {
let dispatch = dispatch.clone();
let kind = *kind;
let hint = *hint;
row = row.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(2.)
.child(
Button::new()
.danger()
.width(Size::fill())
.child(Icon::new(*icon).size(16.))
.text(*text)
.on_press(move |_| {
let target =
cluster_id.read().trim().parse::<i64>().unwrap_or(1);
run_damage(&dispatch, kind, target);
}),
)
.child(
label()
.text(hint)
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
);
}
rows.push(row.into_element());
}
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(
label()
.text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching — which is the point.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
label()
.text("Cluster (for mods)")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::px(120.))
.child(TextInput::new(cluster_id).placeholder("Cluster ID")),
),
)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.children(rows),
)
.child(
label()
.text("Modals: the real prompts, built by the code that raises them for real.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.spacing(12.)
.child(prompt_button(
"Failed assets prompt",
IconType::AlertTriangle,
3,
0,
))
.child(prompt_button(
"Failed libraries prompt",
IconType::AlertCircle,
0,
2,
))
.child(prompt_button("Mixed failures prompt", IconType::File02, 4, 1)),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.spacing(12.)
.child(crash_repair_button(
"Crash repair prompt (named jar)",
Some("fabric-loader-0.15.11.jar"),
cluster_id,
))
.child(crash_repair_button(
"Crash repair prompt (no jar named)",
None,
cluster_id,
)),
)
.into_element()
}
}
/// Raises the genuine "some files could not be downloaded" prompt.
///
/// Calls the same function the download path calls, rather than rebuilding the
/// copy here — a simulated modal that has drifted from the real one is worse
/// than no simulator at all.
fn prompt_button(
text: &'static str,
icon: IconType,
failed_assets: usize,
failed_libraries: usize,
) -> impl IntoElement {
Button::new()
.secondary()
.child(Icon::new(icon).size(16.))
.text(text)
.on_press(move |_| {
spawn(async move {
let Ok(state) = crate::launcher::state() else {
return;
};
let answer = oneclient_core::game::confirm_incomplete_install(
&state.services.mc(),
failed_assets,
failed_libraries,
)
.await;
tracing::info!(?answer, "incomplete install prompt answered");
});
})
}
/// Raises the post-crash repair offer, as if the game had just died on a
/// `java.util.zip.ZipException`.
///
/// Accepting really does run the verify pass, so this exercises the whole path
/// and not just the copy. To test the detection itself rather than the offer,
/// corrupt some libraries above and launch the game — the JVM will raise the
/// real exception and the log watcher will read it out of the output.
fn crash_repair_button(
text: &'static str,
jar: Option<&'static str>,
cluster_id: State<String>,
) -> impl IntoElement {
Button::new()
.secondary()
.child(Icon::new(IconType::AlertCircle).size(16.))
.text(text)
.on_press(move |_| {
let target = cluster_id.read().trim().parse::<i64>().unwrap_or(1);
spawn(async move {
let Ok(state) = crate::launcher::state() else {
return;
};
let diagnosis = oneclient_core::game::CrashDiagnosis::CorruptArchive {
file: jar.map(str::to_string),
};
oneclient_core::game::offer_repair(&state, target, &diagnosis).await;
});
})
}
fn run_damage(dispatch: &crate::Actions, kind: DamageKind, cluster_id: i64) {
let dispatch = dispatch.clone();
spawn(async move {
let result = match kind {
DamageKind::Assets(count, damage) => oneclient_core::simulate::damage_assets(count, damage)
.await
.map(|report| (report, damage.verb())),
DamageKind::Libraries(count, damage) => {
oneclient_core::simulate::damage_libraries(count, damage)
.await
.map(|report| (report, damage.verb()))
}
DamageKind::Content(count, damage) => match crate::launcher::state() {
Ok(state) => oneclient_core::simulate::damage_cluster_content(
&state, cluster_id, count, damage,
)
.await
.map(|report| (report, damage.verb())),
Err(err) => Err(err),
},
DamageKind::DeleteAssetsTree => oneclient_core::simulate::delete_assets_tree()
.await
.map(|report| (report, "Deleted")),
};
match result {
Ok((report, verb)) => {
dispatch
.notify("Damage simulated")
.body(report.summary(verb))
.icon(IconType::AlertTriangle)
.error()
.send();
}
Err(err) => {
dispatch
.notify("Simulation failed")
.body(err.to_string())
.error()
.send();
}
}
});
}
#[derive(PartialEq)]
struct SqlConsole;

View file

@ -295,12 +295,13 @@ async fn download_bundle_if_needed(
}
tracing::debug!("downloading bundle from remote: {url}");
let expected = polyio::Checksum::sha1(expected_sha1);
oneclient_net::download_verified(
&ctx.net,
&ctx.events,
url,
disk_path,
Some(expected_sha1),
Some(&expected),
0,
None,
)

View file

@ -22,14 +22,16 @@ pub async fn ensure_artifact_file(
}
tracing::debug!("downloading artifact file");
// Modrinth and CurseForge both index by SHA-1.
let expected = polyio::Checksum::sha1(hash);
oneclient_net::download_verified(
&ctx.net,
&ctx.events,
url,
dest,
Some(hash),
Some(&expected),
0,
child.cloned(),
child.cloned().map(oneclient_net::ResponseNotifyOptions::grouped),
)
.await?;

View file

@ -7,7 +7,7 @@
//! is why they stay here.
mod migrate;
mod prepare;
pub(crate) mod prepare;
mod provision;
mod unlink_legacy;

View file

@ -82,6 +82,39 @@ impl LauncherError {
_ => None,
}
}
/// Whether this failure reads as "the install is missing pieces", and so is
/// worth repairing rather than just reporting.
///
/// Something like
/// `An error occurred whilst accessing path '.../metadata/assets': No such
/// file or directory (os error 2)` is a dead end as a message: it names a
/// path the user did not create and cannot fix, when the actual answer is
/// that a file the install needs is not there and should be downloaded
/// again.
///
/// Deliberately narrow. Only a genuine not-found reaches here: a permission
/// error, a full disk, or an unreachable network share would all survive a
/// repair unchanged, and re-downloading the game to rediscover that would
/// waste a lot of the user's time.
#[must_use]
pub fn indicates_missing_files(&self) -> bool {
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self);
// Walk the chain rather than matching the top variant: by the time this
// surfaces it has usually been wrapped two or three layers deep, and the
// `io::Error` that actually carries the kind is at the bottom.
while let Some(err) = source {
if let Some(io) = err.downcast_ref::<std::io::Error>()
&& io.kind() == std::io::ErrorKind::NotFound
{
return true;
}
source = err.source();
}
false
}
}
pub trait SentryExclusion {
@ -156,3 +189,65 @@ impl SentryExclusion for polyio::IOError {
}
pub type LauncherResult<T> = Result<T, LauncherError>;
#[cfg(test)]
mod tests {
use super::*;
/// The OS-level "no such file or directory" the filesystem actually
/// returns, rather than a synthesized `ErrorKind`: they map to the same
/// kind, but only this one renders the `(os error 2)` text users report.
fn not_found(path: &str) -> LauncherError {
LauncherError::IoError(polyio::IOError::PathIOError {
source: std::io::Error::from_raw_os_error(2),
path: path.to_string(),
})
}
#[test]
fn a_missing_asset_directory_asks_for_a_repair() {
// The exact shape users report: a path deep in the launcher's own
// metadata that they never created and cannot fix by hand.
let path =
"/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets";
let err = not_found(path);
assert!(err.indicates_missing_files(), "{err}");
assert!(err.to_string().contains(path), "{err}");
}
#[test]
fn a_not_found_nested_several_layers_deep_is_still_found() {
// By the time this surfaces it has usually been wrapped on its way up,
// so matching only the outermost variant would miss it.
let err = LauncherError::McError(oneclient_mc::McError::Io(polyio::IOError::PathIOError {
source: std::io::Error::from(std::io::ErrorKind::NotFound),
path: "metadata/libraries/foo.jar".to_string(),
}));
assert!(err.indicates_missing_files(), "{err}");
}
#[test]
fn failures_a_repair_could_not_fix_are_left_alone() {
// Re-downloading the entire game to rediscover that the disk is full,
// or that the folder is not writable, would waste a lot of the user's
// time and end at the same error.
for kind in [
std::io::ErrorKind::PermissionDenied,
std::io::ErrorKind::ConnectionRefused,
std::io::ErrorKind::TimedOut,
] {
let err = LauncherError::IoError(polyio::IOError::PathIOError {
source: std::io::Error::from(kind),
path: "metadata/assets".to_string(),
});
assert!(!err.indicates_missing_files(), "{kind:?} should not repair");
}
assert!(!LauncherError::NotInitialized.indicates_missing_files());
assert!(
!LauncherError::Minecraft("bad manifest".into()).indicates_missing_files()
);
}
}

View file

@ -0,0 +1,222 @@
//! Reading a crash out of the game's own output.
//!
//! Minecraft's exit code says only that it died. The log says why, and some of
//! the reasons are ones the launcher can fix — so it is worth reading rather
//! than handing the user a stack trace and an exit status.
use std::sync::{Arc, Mutex};
/// A crash cause the launcher recognises and can act on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CrashDiagnosis {
/// The JVM could not read a jar: truncated, wrong bytes, or not a zip at
/// all. On the classpath this is nearly always a damaged library or mod,
/// which is exactly what verification repairs.
CorruptArchive {
/// The jar named in the message, when the JVM included one. Several of
/// these messages carry no path at all, so this is a bonus rather than
/// something to depend on.
file: Option<String>,
},
}
impl CrashDiagnosis {
/// What to tell the user, phrased as the problem rather than the exception.
#[must_use]
pub fn body(&self) -> String {
match self {
Self::CorruptArchive { file: Some(file) } => format!(
"The game could not read {file} — the file is damaged. \
Verifying will re-download anything that does not match."
),
Self::CorruptArchive { file: None } => "The game could not read one of its \
library or mod files — it is damaged. Verifying will re-download \
anything that does not match."
.to_string(),
}
}
}
/// Signatures that all mean "a jar could not be read".
///
/// `ZipException` is the JVM's own; the other two are what the launcher and the
/// `java -jar` front-end print for the same underlying damage. They share a
/// remedy, so they share a diagnosis.
const CORRUPT_ARCHIVE_MARKERS: [&str; 4] = [
"java.util.zip.ZipException",
"java.util.zip.ZipError",
"Invalid or corrupt jarfile",
"zip END header not found",
];
/// Reads one log line, returning a diagnosis when it recognises the failure.
///
/// Runs against every line the game prints, so it stays a handful of substring
/// scans rather than anything that has to parse the line.
#[must_use]
pub fn diagnose(line: &str) -> Option<CrashDiagnosis> {
if !CORRUPT_ARCHIVE_MARKERS
.iter()
.any(|marker| line.contains(marker))
{
return None;
}
Some(CrashDiagnosis::CorruptArchive {
file: jar_in(line),
})
}
/// Pulls a jar path out of a line, when the message happens to carry one.
///
/// `ZipFile` reports "error in opening zip file" with no path on some JVMs and
/// with a full path on others, so this is opportunistic: naming the file is a
/// better message, but not naming it still leads to the same repair.
fn jar_in(line: &str) -> Option<String> {
let token = line
.split(|c: char| c.is_whitespace() || c == '(' || c == ')')
.find(|token| token.trim_end_matches(['.', ',', ':']).ends_with(".jar"))?;
let token = token.trim_end_matches(['.', ',', ':']);
// The full path is noise in a notification; the file name is what the user
// recognises and what they would go looking for.
let name = token
.rsplit(['/', '\\'])
.next()
.unwrap_or(token)
.to_string();
(!name.is_empty()).then_some(name)
}
/// Remembers the first recognised crash cause seen in a session's output.
///
/// The first rather than the last: a corrupt jar tends to cascade into pages of
/// unrelated failures, and the earliest recognised line is the closest thing to
/// a root cause the log offers.
#[derive(Clone, Default)]
pub(crate) struct CrashWatch {
found: Arc<Mutex<Option<CrashDiagnosis>>>,
}
impl CrashWatch {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn observe(&self, line: &str) {
// Cheap rejection first: almost every line is ordinary game output, and
// this runs on all of them.
if self.found.lock().is_ok_and(|found| found.is_some()) {
return;
}
if let Some(diagnosis) = diagnose(line)
&& let Ok(mut found) = self.found.lock()
&& found.is_none()
{
tracing::warn!(?diagnosis, "recognised a repairable crash cause in the game log");
*found = Some(diagnosis);
}
}
pub(crate) fn take(&self) -> Option<CrashDiagnosis> {
self.found.lock().ok().and_then(|mut found| found.take())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_zip_exception_is_recognised() {
// What the JVM prints when a jar on the classpath is damaged.
let diagnosis = diagnose("java.util.zip.ZipException: error in opening zip file");
assert_eq!(
diagnosis,
Some(CrashDiagnosis::CorruptArchive { file: None })
);
}
#[test]
fn a_nested_zip_exception_is_recognised() {
let line = "Caused by: java.util.zip.ZipException: zip END header not found";
assert!(diagnose(line).is_some());
}
#[test]
fn the_jar_is_named_when_the_message_carries_one() {
let line = "java.util.zip.ZipException: error in opening zip file: \
/Users/someone/metadata/libraries/net/fabricmc/fabric-loader-0.15.jar";
assert_eq!(
diagnose(line),
Some(CrashDiagnosis::CorruptArchive {
file: Some("fabric-loader-0.15.jar".to_string()),
})
);
}
#[test]
fn a_windows_path_is_reduced_to_its_file_name() {
let line = r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar";
assert_eq!(
diagnose(line),
Some(CrashDiagnosis::CorruptArchive {
file: Some("asm-9.7.jar".to_string()),
})
);
}
#[test]
fn ordinary_game_output_is_not_a_crash() {
// This runs against every line the game prints, so a false positive
// would nag the user into re-downloading the game for nothing.
for line in [
"[Render thread/INFO]: Setting user: Dev",
"[main/INFO]: Loading 42 mods",
"Loaded jar file sodium-0.5.jar",
"[Worker-Main-1/WARN]: Unable to play unknown soundEvent",
"",
] {
assert_eq!(diagnose(line), None, "{line}");
}
}
#[test]
fn the_watch_keeps_the_first_cause_not_the_last() {
// A corrupt jar cascades, so the earliest recognised line is the one
// closest to the root cause.
let watch = CrashWatch::new();
watch.observe("[main/INFO]: Loading mods");
watch.observe("java.util.zip.ZipException: error in opening zip file: first.jar");
watch.observe("java.util.zip.ZipException: error in opening zip file: second.jar");
assert_eq!(
watch.take(),
Some(CrashDiagnosis::CorruptArchive {
file: Some("first.jar".to_string()),
})
);
}
#[test]
fn a_clean_session_diagnoses_nothing() {
let watch = CrashWatch::new();
watch.observe("[Render thread/INFO]: Stopping!");
assert_eq!(watch.take(), None);
}
#[test]
fn taking_a_diagnosis_consumes_it() {
let watch = CrashWatch::new();
watch.observe("java.util.zip.ZipException: error in opening zip file");
assert!(watch.take().is_some());
assert!(watch.take().is_none(), "a diagnosis must not be reported twice");
}
}

View file

@ -16,7 +16,7 @@ use crate::game::tail::spawn_log_tail;
use crate::game::GameError;
use oneclient_mc::{
self as arguments, download_minecraft, download_version_info, get_loader_version,
libraries_missing, resolve_minecraft_version,
game_files_missing, resolve_minecraft_version,
};
use oneclient_events::{GroupedProgressSession, LaunchStage};
use crate::settings::GameSettingsProfile;
@ -187,7 +187,7 @@ pub async fn launch_cluster(
state.java.prepare(major, search_for_java, false, None).await?
};
match libraries_missing(&version_info, &java.os_arch, updated) {
match game_files_missing(&version_info, &java.os_arch, updated) {
Ok(true) => {
tracing::info!(cluster_id, "missing game files; repairing");
let _ = state.clusters.set_stage(cluster_id, ClusterStage::Repairing).await;
@ -368,7 +368,14 @@ pub async fn launch_cluster(
.as_ref()
.and_then(SessionRecorder::started_at)
.unwrap_or_else(Utc::now);
let tail = spawn_log_tail(cluster_id, log_path, events.clone(), recorder.clone());
let crash_watch = crate::game::diagnosis::CrashWatch::new();
let tail = spawn_log_tail(
cluster_id,
log_path,
events.clone(),
recorder.clone(),
crash_watch.clone(),
);
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
state.games.register_kill(cluster_id, kill_tx);
@ -408,6 +415,7 @@ pub async fn launch_cluster(
ended_at: Utc::now(),
outcome,
owns_slot: true,
diagnosis: crash_watch.take(),
},
)
.await;
@ -472,6 +480,10 @@ pub(crate) struct SessionEnd {
/// playtime is right, but clearing that slot would report the live game as
/// exited.
pub owns_slot: bool,
/// What the game's own output said about why it died, when the launcher
/// recognised the cause. `None` for a clean exit, an unrecognised crash, or
/// a session recovered after the fact with no log being watched.
pub diagnosis: Option<crate::game::diagnosis::CrashDiagnosis>,
}
/// Everything that has to happen once a game is gone: clear its running state,
@ -530,6 +542,8 @@ pub(crate) async fn finalize_session(
}
let name = &cluster.name;
let crashed = !matches!(end.outcome, Exit::Observed { success: true, .. });
match end.outcome {
Exit::Observed { success: true, .. } => state
.services
@ -547,6 +561,78 @@ pub(crate) async fn finalize_session(
// user wants a popup about, so the session is just booked and closed.
Exit::Inferred => {}
}
// Only after the crash notice, and only when the game actually died: a
// `ZipException` the game recovered from is not worth interrupting a
// finished session over.
if crashed && let Some(diagnosis) = end.diagnosis {
offer_repair(state, cluster_id, &diagnosis).await;
}
}
/// Offers to verify and repair after a crash the launcher recognised.
///
/// Asks rather than acting: verification reads every installed file and can
/// re-download a good part of the game, which is not something to start behind
/// the user's back the moment they close a crashed session. Declining is a real
/// answer — the same offer comes back on the next crash.
#[tracing::instrument(skip(state, diagnosis), level = "debug")]
pub async fn offer_repair(
state: &Arc<LauncherState>,
cluster_id: i64,
diagnosis: &crate::game::diagnosis::CrashDiagnosis,
) {
enum Answer {
Verify,
}
let answer = state
.services
.events
.ask(
oneclient_events::Prompt::new("A damaged file crashed the game", diagnosis.body())
.option(
oneclient_events::Choice::primary("verify", "Verify and repair"),
Answer::Verify,
)
.dismiss("Not now"),
)
.await;
match answer {
Ok(Some(_)) => {}
// Dismissed, or nobody was there to ask. Neither is consent to spend
// several minutes re-downloading the game.
Ok(None) => {
tracing::info!(cluster_id, "user declined the post-crash repair");
return;
}
Err(err) => {
tracing::warn!(cluster_id, "could not offer a post-crash repair: {err}");
return;
}
}
match crate::verify::verify_cluster_files(state, cluster_id).await {
Ok(report) => {
state
.services
.events
.notify("Repair complete")
.body(report.summary())
.send();
}
Err(err) => {
tracing::error!(cluster_id, "post-crash repair failed: {err:#}");
state
.services
.events
.notify("Repair failed")
.body(err.to_string())
.error()
.send();
}
}
}
fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command {

View file

@ -19,16 +19,21 @@ pub use oneclient_mc::{
get_classpath_library, get_library, java_arguments, main_class, minecraft_arguments,
processor_arguments,
};
pub mod diagnosis;
pub use diagnosis::{CrashDiagnosis, diagnose};
pub use error::GameError;
pub use launch::{LaunchedGame, is_running, launch_cluster};
pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair};
pub use process::{
GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time,
};
pub use reattach::recover_sessions;
pub use oneclient_mc::{
DownloadPlan, download_minecraft, download_version_info, get_game_versions,
DownloadPlan, confirm_incomplete_install, download_minecraft, download_version_info, get_game_versions,
get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated,
libraries_missing, plan_downloads, resolve_minecraft_version, validate_rules,
game_files_missing, libraries_missing, plan_downloads, resolve_minecraft_version,
validate_rules,
verify_game_files,
};
pub use shared_dir::{
dematerialize_content, import_manual_content, link_cluster_logs, materialize_content,

View file

@ -163,11 +163,16 @@ async fn readopt(
let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else {
return;
};
// A re-adopted game is still writing to its log, so its crashes are still
// worth reading even though the exit code went with the launcher that
// spawned it.
let crash_watch = crate::game::diagnosis::CrashWatch::new();
let tail = spawn_log_tail(
cluster_id,
log_path,
state.services.events.clone(),
Some(recorder.clone()),
crash_watch.clone(),
);
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
@ -213,6 +218,7 @@ async fn readopt(
// fresh game on this cluster while the re-adopted one plays on,
// and that newer game now owns the slot.
owns_slot: state.games.pid(cluster_id) == Some(pid),
diagnosis: crash_watch.take(),
},
)
.await;
@ -297,6 +303,9 @@ async fn reconcile(
// This session is over, but the cluster may have a newer one playing right
// now, so only claim the slot if nothing else holds it.
owns_slot: !state.games.is_active(cluster_id),
// Nothing was watching this session's log while it ran, so there is
// no crash to have recognised.
diagnosis: None,
},
)
.await;

View file

@ -5,6 +5,7 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader};
use crate::game::diagnosis::CrashWatch;
use crate::game::session::SessionRecorder;
use oneclient_events::EventBus;
@ -30,12 +31,13 @@ pub(crate) fn spawn_log_tail(
path: PathBuf,
events: EventBus,
recorder: Option<SessionRecorder>,
crash_watch: CrashWatch,
) -> LogTail {
let stop = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&stop);
let handle = tokio::spawn(async move {
if let Err(err) = tail(cluster_id, &path, &events, recorder.as_ref(), &flag).await {
if let Err(err) = tail(cluster_id, &path, &events, recorder.as_ref(), &crash_watch, &flag).await {
tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped");
}
});
@ -48,6 +50,7 @@ async fn tail(
path: &PathBuf,
events: &EventBus,
recorder: Option<&SessionRecorder>,
crash_watch: &CrashWatch,
stop: &AtomicBool,
) -> std::io::Result<()> {
// The game may not have created the file yet on a re-adopt, and on launch it
@ -96,7 +99,7 @@ async fn tail(
// leaves a newline that will never arrive, and waiting for it would
// hang the caller of `stop()` forever.
if stop.load(Ordering::Relaxed) {
emit(cluster_id, &line, events, recorder).await;
emit(cluster_id, &line, events, recorder, crash_watch).await;
return Ok(());
}
reader.seek(std::io::SeekFrom::Start(offset - read as u64)).await?;
@ -105,7 +108,7 @@ async fn tail(
continue;
}
emit(cluster_id, &line, events, recorder).await;
emit(cluster_id, &line, events, recorder, crash_watch).await;
}
}
@ -114,6 +117,7 @@ async fn emit(
line: &str,
events: &EventBus,
recorder: Option<&SessionRecorder>,
crash_watch: &CrashWatch,
) {
// Blank lines are kept: the game prints them, and the console should read
// the way the log does.
@ -121,6 +125,9 @@ async fn emit(
if let Some(recorder) = recorder {
recorder.observe(&text).await;
}
// Read on the way past rather than by re-opening the log afterwards: the
// line is already here, and a crash can take the log with it.
crash_watch.observe(&text);
events.game_log(cluster_id, text);
}
@ -175,7 +182,7 @@ mod tests {
let path = h.path();
polyio::write(&path, "[12:00:00] first\n").await.unwrap();
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
settle().await;
let mut file = tokio::fs::OpenOptions::new()
@ -196,7 +203,7 @@ mod tests {
let mut h = Harness::new().await;
let path = h.path();
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
settle().await;
polyio::write(&path, "[12:00:00] late\n").await.unwrap();
settle().await;
@ -211,7 +218,7 @@ mod tests {
let path = h.path();
polyio::write(&path, "[12:00:00] half").await.unwrap();
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
settle().await;
// A line mid-write must not be emitted, or it would arrive twice.
assert!(h.drain().is_empty());
@ -235,7 +242,7 @@ mod tests {
let path = h.path();
polyio::write(&path, "[12:00:00] old session line\n").await.unwrap();
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
settle().await;
assert_eq!(h.drain(), vec!["[12:00:00] old session line"]);
@ -255,7 +262,7 @@ mod tests {
// A process killed mid-write leaves a newline that never arrives.
polyio::write(&path, "[12:00:00] cut off mid-writ").await.unwrap();
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
settle().await;
// Waiting for that newline would hang the exit path forever.
@ -272,7 +279,7 @@ mod tests {
// Stopping immediately, as the exit path does, must still flush the
// reason the game exited rather than race past it.
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None);
let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new());
tail.stop().await;
assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]);

View file

@ -17,6 +17,8 @@ pub mod settings;
pub mod storage;
pub mod tos;
mod state;
pub mod simulate;
pub mod verify;
pub mod versions;
pub use oneclient_content::bundles::{
@ -60,6 +62,7 @@ pub use oneclient_content::packages::updates::{
refresh_browser_package_updates, skip_browser_package_update,
};
pub use settings::ProfileUpdate;
pub use verify::{ClusterVerifyReport, verify_cluster_files};
pub use state::LauncherServices;
pub use state::LauncherState;
pub use state::run_startup_tasks;

View file

@ -0,0 +1,398 @@
//! Deliberately damaging an installation, so the repair paths can be exercised
//! without waiting for a real bad connection.
//!
//! Every function here breaks files in a *specific* way, because the launcher
//! catches different damage at different layers and the distinction is the
//! whole point of testing it:
//!
//! - **Same length, wrong bytes** slips past the launch-time size check by
//! design, and is only caught by the hashing pass behind "Verify Files".
//! - **Truncated** is what an interrupted download used to leave behind, and is
//! caught cheaply on the next launch.
//! - **Absent** is the case that used to surface as a raw "No such file or
//! directory" naming a path inside the launcher's own metadata folder.
//!
//! Destructive by nature. Everything is repairable by the launcher itself,
//! which is what makes it safe to point at a real installation.
use std::path::{Path, PathBuf};
use oneclient_content::packages::PackageStore;
use oneclient_content::packages::store::artifact_absolute_path;
use oneclient_common::paths;
use oneclient_db::dao::artifact as artifact_dao;
use crate::state::LauncherState;
use crate::LauncherResult;
/// What a simulation actually did, so the UI can say so rather than claiming
/// success over an installation that had nothing to break.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SimulationReport {
pub affected: usize,
/// A few of the paths touched, for a notification body. Not the full list:
/// corrupting 50 assets should not produce a 50-line toast.
pub samples: Vec<String>,
}
impl SimulationReport {
#[must_use]
pub fn summary(&self, verb: &str) -> String {
if self.affected == 0 {
return "Nothing to affect — is the game installed?".to_string();
}
let mut text = format!("{verb} {} file(s)", self.affected);
if let Some(first) = self.samples.first() {
text.push_str(&format!(": {first}"));
if self.affected > 1 {
text.push_str(&format!(" and {} more", self.affected - 1));
}
}
text
}
fn push(&mut self, path: &Path) {
self.affected += 1;
if self.samples.len() < 3 {
self.samples.push(
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
);
}
}
}
/// How a file should be damaged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Damage {
/// Flip bytes in the middle, keeping the length. Passes a size check and
/// fails a hash check — the case "Verify Files" exists for.
Corrupt,
/// Cut the file to half its length, as an interrupted write would.
Truncate,
/// Remove it outright.
Delete,
}
impl Damage {
#[must_use]
pub fn verb(self) -> &'static str {
match self {
Self::Corrupt => "Corrupted",
Self::Truncate => "Truncated",
Self::Delete => "Deleted",
}
}
fn apply(self, path: &Path) -> std::io::Result<()> {
match self {
Self::Delete => std::fs::remove_file(path),
Self::Truncate => {
let len = std::fs::metadata(path)?.len();
let file = std::fs::OpenOptions::new().write(true).open(path)?;
file.set_len(len / 2)
}
Self::Corrupt => {
let mut bytes = std::fs::read(path)?;
if bytes.is_empty() {
return Ok(());
}
// Flipping every bit of a few bytes in the middle guarantees a
// different hash without changing the length, and lands past
// any header a reader might validate first — so the file stays
// superficially plausible, exactly like real bit rot.
let start = bytes.len() / 2;
let end = (start + 8).min(bytes.len());
for byte in &mut bytes[start..end] {
*byte = !*byte;
}
std::fs::write(path, &bytes)
}
}
}
}
/// Damages `count` asset objects, spread across the sharded subdirectories.
///
/// Spread rather than taken in bulk from one shard because that is how real
/// damage arrives — a handful of failed requests scattered through a download —
/// and because it proves the verify pass walks the whole tree.
#[tracing::instrument(level = "debug")]
pub async fn damage_assets(count: usize, damage: Damage) -> LauncherResult<SimulationReport> {
let dir = paths::assets_object_dir()?;
tokio::task::spawn_blocking(move || {
let mut report = SimulationReport::default();
let Ok(shards) = std::fs::read_dir(&dir) else {
return report;
};
// One file per shard before taking a second from any, so a small count
// never all lands in `00/`.
let mut shard_dirs: Vec<PathBuf> = shards
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
shard_dirs.sort();
let mut round = 0;
while report.affected < count {
let mut progressed = false;
for shard in &shard_dirs {
if report.affected >= count {
break;
}
let Ok(entries) = std::fs::read_dir(shard) else {
continue;
};
let Some(path) = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file())
.nth(round)
else {
continue;
};
progressed = true;
match damage.apply(&path) {
Ok(()) => report.push(&path),
Err(err) => {
tracing::warn!(path = %path.display(), "could not damage: {err}");
}
}
}
// Every shard is exhausted; asking for more than exists is not an
// error, there simply are not that many assets installed.
if !progressed {
break;
}
round += 1;
}
tracing::warn!(
affected = report.affected,
?damage,
"simulated asset damage"
);
report
})
.await
.map_err(std::io::Error::other)
.map_err(Into::into)
}
/// Damages the installed libraries, which are on the classpath and so tend to
/// stop the game outright rather than degrade it.
#[tracing::instrument(level = "debug")]
pub async fn damage_libraries(count: usize, damage: Damage) -> LauncherResult<SimulationReport> {
let dir = paths::libraries_dir()?;
tokio::task::spawn_blocking(move || {
let mut report = SimulationReport::default();
let mut stack = vec![dir];
while let Some(current) = stack.pop() {
if report.affected >= count {
break;
}
let Ok(entries) = std::fs::read_dir(&current) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|ext| ext != "jar") {
continue;
}
if report.affected >= count {
break;
}
match damage.apply(&path) {
Ok(()) => report.push(&path),
Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"),
}
}
}
tracing::warn!(
affected = report.affected,
?damage,
"simulated library damage"
);
report
})
.await
.map_err(std::io::Error::other)
.map_err(Into::into)
}
/// Removes the whole assets tree.
///
/// Reproduces the reported failure exactly: a cluster already marked `Ready`
/// skips preparation on every later launch, so nothing notices until something
/// tries to read a path that is no longer there.
#[tracing::instrument(level = "debug")]
pub async fn delete_assets_tree() -> LauncherResult<SimulationReport> {
let dir = paths::assets_dir()?;
let existed = polyio::try_exists(&dir).await.unwrap_or(false);
if existed {
tokio::fs::remove_dir_all(&dir)
.await
.map_err(std::io::Error::other)?;
}
tracing::warn!(path = %dir.display(), existed, "simulated a missing assets tree");
Ok(SimulationReport {
affected: usize::from(existed),
samples: vec![dir.display().to_string()],
})
}
/// Damages the installed content of one cluster.
///
/// Content is cached content-addressed, so a mod that no longer hashes to its
/// own key cannot be found again — the repair has to go back to the provider
/// for it, which is a different path from the game files.
#[tracing::instrument(level = "debug", skip(state))]
pub async fn damage_cluster_content(
state: &std::sync::Arc<LauncherState>,
cluster_id: i64,
count: usize,
damage: Damage,
) -> LauncherResult<SimulationReport> {
let content = state.services.content();
let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?;
let mut paths = Vec::new();
for link in linked {
let Some(artifact) =
artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await?
else {
continue;
};
if let Ok(path) = artifact_absolute_path(&artifact.path) {
paths.push(path);
}
if paths.len() >= count {
break;
}
}
tokio::task::spawn_blocking(move || {
let mut report = SimulationReport::default();
for path in paths {
if !path.is_file() {
continue;
}
match damage.apply(&path) {
Ok(()) => report.push(&path),
Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"),
}
}
tracing::warn!(
affected = report.affected,
?damage,
"simulated content damage"
);
report
})
.await
.map_err(std::io::Error::other)
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"oneclient-simulate-{tag}-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn corrupting_keeps_the_length_and_changes_the_bytes() {
// The whole point of this mode: it has to survive a size check so that
// only the hashing pass can catch it.
let dir = scratch("corrupt");
let file = dir.join("object.bin");
let original: Vec<u8> = (0..=255u8).collect();
std::fs::write(&file, &original).unwrap();
Damage::Corrupt.apply(&file).unwrap();
let after = std::fs::read(&file).unwrap();
assert_eq!(after.len(), original.len(), "length must not change");
assert_ne!(after, original, "bytes must change");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn corrupting_an_empty_file_is_a_no_op_rather_than_a_panic() {
let dir = scratch("corrupt-empty");
let file = dir.join("empty.bin");
std::fs::write(&file, []).unwrap();
Damage::Corrupt.apply(&file).unwrap();
assert!(std::fs::read(&file).unwrap().is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn truncating_halves_the_file() {
let dir = scratch("truncate");
let file = dir.join("object.bin");
std::fs::write(&file, vec![7u8; 100]).unwrap();
Damage::Truncate.apply(&file).unwrap();
assert_eq!(std::fs::metadata(&file).unwrap().len(), 50);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_report_over_nothing_says_so() {
let report = SimulationReport::default();
assert_eq!(
report.summary("Corrupted"),
"Nothing to affect — is the game installed?"
);
}
#[test]
fn a_report_names_a_sample_and_counts_the_rest() {
let mut report = SimulationReport::default();
report.push(Path::new("/tmp/assets/objects/00/aaaa"));
report.push(Path::new("/tmp/assets/objects/01/bbbb"));
report.push(Path::new("/tmp/assets/objects/02/cccc"));
report.push(Path::new("/tmp/assets/objects/03/dddd"));
assert_eq!(report.affected, 4);
// Only a few names are kept, so a large run cannot produce a toast the
// size of the asset index.
assert_eq!(report.samples.len(), 3);
assert_eq!(report.summary("Corrupted"), "Corrupted 4 file(s): aaaa and 3 more");
}
}

View file

@ -0,0 +1,452 @@
use std::sync::Arc;
use oneclient_cluster::ClusterError;
use oneclient_content::packages::PackageStore;
use oneclient_content::packages::store::artifact_absolute_path;
use oneclient_db::dao::artifact as artifact_dao;
use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase};
use oneclient_common::paths;
use polyio::{normalize_hash, sha1_file};
use crate::clusters::prepare::prepare_cluster_locked;
use crate::game::{
download_version_info, get_loader_version, resolve_minecraft_version, verify_game_files,
};
use crate::state::LauncherState;
use crate::LauncherResult;
/// What a verify pass found and what it managed to do about it.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ClusterVerifyReport {
/// Files read and hashed, across game files and installed content.
pub checked: usize,
/// Files whose contents did not match and were removed.
pub corrupt: usize,
/// Files a manifest expected that were not on disk at all.
pub missing: usize,
/// Files that were successfully fetched again.
pub repaired: usize,
/// Content that was corrupt but has no source to re-download from —
/// locally imported files, or a version the provider no longer serves.
/// Named so the user can replace them by hand.
pub unrepairable: Vec<String>,
/// The game files were re-downloaded without being counted, because the
/// assets index they would have been checked against was itself gone.
/// Reported separately so a wholesale reinstall is never described as a
/// clean sweep of the handful of files that could still be counted.
pub reinstalled_game_files: bool,
}
impl ClusterVerifyReport {
#[must_use]
pub fn is_clean(&self) -> bool {
self.corrupt == 0
&& self.missing == 0
&& self.unrepairable.is_empty()
&& !self.reinstalled_game_files
}
/// A one-line summary for a notification.
#[must_use]
pub fn summary(&self) -> String {
if self.is_clean() {
return format!("All {} files verified.", self.checked);
}
if self.reinstalled_game_files && self.corrupt == 0 && self.missing == 0 {
return "Game files were missing and have been re-downloaded.".to_string();
}
let mut parts = Vec::new();
if self.corrupt > 0 {
parts.push(format!("{} corrupt", self.corrupt));
}
if self.missing > 0 {
parts.push(format!("{} missing", self.missing));
}
if self.repaired > 0 {
parts.push(format!("{} repaired", self.repaired));
}
if !self.unrepairable.is_empty() {
parts.push(format!("{} could not be replaced", self.unrepairable.len()));
}
format!("Checked {} files: {}.", self.checked, parts.join(", "))
}
}
/// Verifies every file a cluster installed, and re-downloads what is broken.
///
/// Runs in three passes. Game files are hashed against the Mojang manifest and
/// the bad ones deleted; installed content is hashed against the hash it is
/// indexed under and the bad ones deleted and re-fetched from the provider that
/// served them; then the ordinary prepare path runs, which re-downloads exactly
/// what is now missing using the same verified, retrying download every other
/// install goes through.
///
/// Deleting and re-preparing rather than overwriting in place is deliberate: it
/// reuses one well-tested download path instead of adding a second one that
/// would have to be kept in step with it.
#[tracing::instrument(skip(state), level = "debug")]
pub async fn verify_cluster_files(
state: &Arc<LauncherState>,
cluster_id: i64,
) -> LauncherResult<ClusterVerifyReport> {
let cluster = state.clusters.get(cluster_id).await?;
let progress = GroupedProgressSession::start(
&state.services.events,
format!("Verifying files - {}", cluster.mc_version),
);
let result = run_verify(state, cluster_id, &progress).await;
progress.finish();
match &result {
Ok(report) => tracing::info!(
cluster_id,
checked = report.checked,
corrupt = report.corrupt,
missing = report.missing,
repaired = report.repaired,
"cluster verification finished"
),
Err(err) => tracing::error!(cluster_id, "cluster verification failed: {err}"),
}
result
}
async fn run_verify(
state: &Arc<LauncherState>,
cluster_id: i64,
progress: &GroupedProgressSession,
) -> LauncherResult<ClusterVerifyReport> {
let cluster = state.clusters.get(cluster_id).await?;
let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version);
// Resolving the version needs the metadata lock, which prepare also takes.
// Scope it so it is released before the repair pass below.
let (version_info, minecraft_updated) = {
let mut metadata = state.metadata.lock().await;
let (version, _index, minecraft_updated) =
resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version)
.await
.map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?;
let loader_version = get_loader_version(
&mut metadata,
&state.services.mc(),
&mc_version,
cluster.mc_loader,
cluster.mc_loader_version.as_deref(),
)
.await?;
let info = download_version_info(
&state.services.mc(),
Some(progress),
&version,
loader_version.as_ref(),
false,
)
.await?;
(info, minecraft_updated)
};
let mut report = ClusterVerifyReport::default();
// The index is needed to know which assets this version expects. Reading
// the cached copy avoids a network round trip; if it is absent or itself
// corrupt there is nothing to verify assets against, so skip that part
// rather than fail — prepare will fetch it and the assets below.
let assets_index = cached_assets_index(&version_info).await;
// Library rules are evaluated per architecture, so checking against the
// wrong one would verify a different set of libraries than the launch path
// installs. Use the runtime this cluster actually launches with.
let java_arch = {
let global = state.settings.read().global_game_settings.clone();
let profile = state.clusters.resolve_settings(&global, &cluster).await?;
state
.java
.runtime_for_profile(profile.java_path.as_deref())
.await
.ok()
.flatten()
.map_or_else(|| std::env::consts::ARCH.to_string(), |rt| rt.os_arch)
};
// Counted apart from content: these are repaired by the prepare pass below,
// whereas content repairs itself as it goes. Adding both into one bucket
// would count the content ones twice.
let mut game_broken = 0;
// Without the index there is nothing to check the assets against — and the
// index lives inside the assets directory, so its absence usually means
// that whole directory is gone. That is precisely the case this repair
// exists for, so hand the work to prepare rather than reporting a clean
// sweep of the nothing that could be checked.
let reinstalled_game_files = assets_index.is_none();
if let Some(assets_index) = assets_index {
let game = verify_game_files(
progress,
&version_info,
assets_index,
&java_arch,
minecraft_updated,
)
.await?;
report.checked += game.checked;
report.corrupt += game.corrupt;
report.missing += game.missing;
game_broken = game.corrupt + game.missing;
} else {
tracing::warn!("assets index is missing; re-downloading the game files wholesale");
}
verify_cluster_content(state, cluster_id, progress, &mut report).await?;
// Everything corrupt is gone, so a normal prepare sees it as missing and
// fetches it back through the verified, retrying download path. A file it
// cannot get surfaces as an error rather than a silent gap, so reaching the
// line after this means the game files are whole again.
//
// Note this runs `prepare` directly rather than going through the launch
// path, which skips preparing a cluster already marked `Ready` — the very
// reason a cluster with deleted files keeps failing to launch instead of
// healing itself.
if game_broken > 0 || reinstalled_game_files {
prepare_cluster_locked(state, cluster_id, false, true, true, Some(progress)).await?;
report.repaired += game_broken;
}
report.reinstalled_game_files = reinstalled_game_files;
Ok(report)
}
/// Hashes the cluster's installed content against the hashes it is indexed
/// under, and re-fetches whatever no longer matches.
///
/// Content is cached content-addressed, so a file that does not hash to its own
/// key is unusable by definition: nothing can find it again, and materializing
/// it into the game folder would put a corrupt mod in front of the game.
async fn verify_cluster_content(
state: &Arc<LauncherState>,
cluster_id: i64,
progress: &GroupedProgressSession,
report: &mut ClusterVerifyReport,
) -> LauncherResult<()> {
let content = state.services.content();
let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?;
if linked.is_empty() {
return Ok(());
}
let child = progress.child(
"Verifying content",
linked.len() as u64,
TaskCategory::Packages,
);
child.set_phase(TaskPhase::Verifying);
for (index, link) in linked.iter().enumerate() {
child.set_progress(index as u64, Some(linked.len() as u64));
let Some(artifact) = artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash)
.await?
else {
continue;
};
let Ok(path) = artifact_absolute_path(&artifact.path) else {
continue;
};
if !path.is_file() {
report.missing += 1;
repair_content(state, link, report).await;
continue;
}
let actual = match sha1_file(&path).await {
Ok(actual) => actual,
Err(err) => {
tracing::warn!(path = %path.display(), "could not hash content: {err}");
report.corrupt += 1;
let _ = polyio::remove_file(&path).await;
repair_content(state, link, report).await;
continue;
}
};
report.checked += 1;
if normalize_hash(&actual) == normalize_hash(&link.hash) {
continue;
}
tracing::warn!(
file = %link.file_name,
expected = %link.hash,
%actual,
"corrupt content file; removing"
);
report.corrupt += 1;
let _ = polyio::remove_file(&path).await;
repair_content(state, link, report).await;
}
child.finish();
Ok(())
}
/// Re-downloads one piece of content from the provider that served it.
///
/// Failures are recorded rather than propagated: one mod that a provider has
/// since delisted should not abandon verification of everything else. A locally
/// imported file has no provider at all, and the user is the only one who can
/// replace it — so it is named in the report instead of silently vanishing.
async fn repair_content(
state: &Arc<LauncherState>,
link: &oneclient_content::packages::LinkedArtifactInfo,
report: &mut ClusterVerifyReport,
) {
let content = state.services.content();
let (Some(provider_id), Some(project_id), Some(version_id)) = (
link.provider,
link.project_id.as_deref(),
link.version_id.as_deref(),
) else {
tracing::warn!(file = %link.file_name, "no provider to re-download from");
report.unrepairable.push(link.file_name.clone());
return;
};
let outcome = async {
let provider = content.providers.get(provider_id)?;
let version = provider.get_version(project_id, version_id, &content).await?;
// The cluster is pinned to one exact file, identified by the hash it is
// indexed under. Re-downloading a different file from the same version
// would silently change what is installed.
let file = version
.files
.iter()
.find(|file| normalize_hash(&file.sha1) == normalize_hash(&link.hash))
.ok_or_else(|| oneclient_content::ContentError::InvalidData {
reason: format!("{} is no longer served by {provider_id:?}", link.file_name),
})?;
oneclient_content::packages::store::download_version_file(
provider_id,
project_id,
&version,
link.content_type,
file,
true,
None,
&content,
)
.await
}
.await;
match outcome {
Ok(_) => {
tracing::info!(file = %link.file_name, "re-downloaded corrupt content");
report.repaired += 1;
}
Err(err) => {
tracing::warn!(file = %link.file_name, "could not re-download: {err}");
report.unrepairable.push(link.file_name.clone());
}
}
}
async fn cached_assets_index(
info: &interfrost::api::minecraft::VersionInfo,
) -> Option<interfrost::api::minecraft::AssetsIndex> {
let path = paths::assets_index_dir()
.ok()?
.join(format!("{}.json", info.asset_index.id));
polyio::read_json(&path).await.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_clean_sweep_says_so() {
let report = ClusterVerifyReport {
checked: 5123,
..Default::default()
};
assert!(report.is_clean());
assert_eq!(report.summary(), "All 5123 files verified.");
}
#[test]
fn a_repaired_sweep_reports_what_it_did() {
let report = ClusterVerifyReport {
checked: 5123,
corrupt: 3,
missing: 1,
repaired: 4,
unrepairable: Vec::new(),
reinstalled_game_files: false,
};
assert!(!report.is_clean());
assert_eq!(
report.summary(),
"Checked 5123 files: 3 corrupt, 1 missing, 4 repaired."
);
}
#[test]
fn a_wholesale_reinstall_is_not_reported_as_a_clean_sweep() {
// The `metadata/assets` case: the index the assets would have been
// checked against was gone too, so almost nothing could be counted.
// Saying "All 2 files verified" after re-downloading the game would be
// actively misleading.
let report = ClusterVerifyReport {
checked: 2,
reinstalled_game_files: true,
..Default::default()
};
assert!(!report.is_clean());
assert_eq!(
report.summary(),
"Game files were missing and have been re-downloaded."
);
}
#[test]
fn content_that_cannot_be_replaced_is_still_reported() {
// A locally imported mod has no provider to fetch it from again, so the
// user is the only one who can put it back. Saying nothing would leave
// them with a cluster that is quietly missing a file.
let report = ClusterVerifyReport {
checked: 12,
corrupt: 1,
repaired: 0,
unrepairable: vec!["my-local-mod.jar".to_string()],
..Default::default()
};
assert!(!report.is_clean());
assert_eq!(
report.summary(),
"Checked 12 files: 1 corrupt, 1 could not be replaced."
);
}
}

View file

@ -1,5 +1,6 @@
use std::path::PathBuf;
use polyio::Checksum;
use serde::{Deserialize, Serialize};
use crate::vendors::JavaVendor;
@ -21,6 +22,41 @@ pub struct JavaPackage {
pub java_version: Vec<u32>,
pub vendor: JavaVendor,
pub archive: PackageArchive,
/// What the vendor promises the archive hashes to. Every vendor publishes
/// one, in its own algorithm — SHA-256 for Adoptium, Corretto and Zulu,
/// SHA-1 for Liberica — so this carries the algorithm with it.
///
/// `None` only when a vendor's metadata omitted or malformed it. A runtime
/// that cannot be verified is still worth installing (the alternative is a
/// launcher that cannot start the game at all), so this degrades to an
/// unverified download rather than a hard failure.
pub checksum: Option<Checksum>,
/// Archive size in bytes when the vendor publishes it, for progress bars
/// that would otherwise wait on `Content-Length`.
pub size: Option<u64>,
}
impl JavaPackage {
/// Drops a checksum the vendor returned in an unusable shape.
///
/// A vendor that starts sending an empty string or a placeholder would
/// otherwise make every runtime install fail with what looks to the user
/// like a corrupt download, three retries deep.
#[must_use]
pub fn with_checksum(mut self, checksum: Option<Checksum>) -> Self {
self.checksum = checksum.filter(|sum| {
let ok = sum.is_well_formed();
if !ok {
tracing::warn!(
algorithm = sum.algorithm.name(),
hex = %sum.hex,
"vendor published a malformed checksum; installing unverified"
);
}
ok
});
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -1,9 +1,6 @@
use std::path::{Path, PathBuf};
use reqwest::Method;
use url::Url;
use oneclient_net::{ResponseNotifyOptions, ResponseOptions};
use oneclient_net::ResponseNotifyOptions;
use crate::data::{
java_executable_relative_path, JavaPackage, PackageArchive,
};
@ -32,8 +29,14 @@ pub async fn install_package(
// settings, say) gets its own notification.
// No size in the package metadata; the child picks up its real total from
// Content-Length as soon as the response headers land.
let child = progress
.map(|session| session.child(format!("{} {}", package.vendor, major), 0, TaskCategory::Java));
let expected_size = package.size.unwrap_or(0);
let child = progress.map(|session| {
session.child(
format!("{} {}", package.vendor, major),
expected_size,
TaskCategory::Java,
)
});
let notify = match &child {
Some(child) => ResponseNotifyOptions::grouped(child.clone()),
@ -44,16 +47,30 @@ pub async fn install_package(
.done_label(format!("Installed {} {}", package.vendor, major)),
};
net
.download_file(
reqwest::Request::new(Method::GET, Url::parse(&package.download_url)?),
&archive_path,
ResponseOptions {
notify: Some(notify),
},
events,
)
.await?;
if package.checksum.is_none() {
tracing::warn!(
vendor = %package.vendor,
major,
"vendor published no usable checksum; installing runtime unverified"
);
}
// A runtime is the largest single file the launcher downloads, so it is the
// most likely to be interrupted — and it used to be the one download with no
// verification at all. Now it is hashed from the bytes in flight against the
// vendor's own checksum, retried on a dropped connection, and only published
// to `archive_path` once it matches, so extraction never sees a partial
// archive.
oneclient_net::download_verified(
net,
events,
&package.download_url,
&archive_path,
package.checksum.as_ref(),
expected_size,
Some(notify),
)
.await?;
let extract_root = java_dir.join(stem_without_archive(&package.name));

View file

@ -3,6 +3,7 @@ use serde::Deserialize;
use url::Url;
use oneclient_net::RequestClient;
use polyio::Checksum;
use crate::data::{JavaPackage, PackageArchive};
use crate::error::JavaResult;
@ -38,6 +39,12 @@ struct AdoptiumBinary {
struct AdoptiumPackage {
name: String,
link: String,
/// Adoptium's `checksum` is SHA-256; the API has no algorithm field because
/// it has only ever published one.
#[serde(default)]
checksum: Option<String>,
#[serde(default)]
size: Option<u64>,
}
#[async_trait::async_trait]
@ -87,13 +94,20 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider {
}
for binary in release.binaries {
packages.push(JavaPackage {
archive: PackageArchive::from_filename(&binary.package.name),
download_url: binary.package.link,
java_version: java_version.clone(),
name: binary.package.name,
vendor: JavaVendor::Adoptium,
});
let checksum = binary.package.checksum.as_deref().map(Checksum::sha256);
packages.push(
JavaPackage {
archive: PackageArchive::from_filename(&binary.package.name),
download_url: binary.package.link,
java_version: java_version.clone(),
name: binary.package.name,
vendor: JavaVendor::Adoptium,
checksum: None,
size: binary.package.size,
}
.with_checksum(checksum),
);
}
}

View file

@ -3,6 +3,7 @@ use serde_json::Value;
use url::Url;
use oneclient_net::RequestClient;
use polyio::Checksum;
use crate::data::{JavaPackage, PackageArchive};
use crate::error::JavaResult;
@ -52,26 +53,47 @@ impl JavaRuntimeProvider for CorrettoRuntimeProvider {
continue;
}
let has_ext = by_ext
.as_object()
.is_some_and(|exts| exts.contains_key(CORRETTO_EXT.0));
if !has_ext {
let Some(entry) = by_ext.get(CORRETTO_EXT.0) else {
continue;
}
};
// Prefer the exact versioned URL the index names over the `latest`
// redirect. Both resolve to the same build today, but `latest` is
// resolved at download time: if Corretto publishes a new build in
// the window between listing and downloading, the redirect moves and
// the checksum from this index no longer describes those bytes. The
// download would then fail as corrupt, three retries deep, for a
// file that is perfectly intact.
let download_url = entry
.get("resource")
.and_then(Value::as_str)
.map_or_else(
|| latest_url(this_major, CORRETTO_EXT.0),
|resource| format!("https://corretto.aws{resource}"),
);
let checksum = entry
.get("checksum_sha256")
.and_then(Value::as_str)
.map(Checksum::sha256);
let download_url = latest_url(this_major, CORRETTO_EXT.0);
let name = format!(
"amazon-corretto-{this_major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{}",
CORRETTO_EXT.0
);
packages.push(JavaPackage {
archive: CORRETTO_EXT.1,
download_url,
java_version: vec![this_major],
name,
vendor: JavaVendor::Corretto,
});
packages.push(
JavaPackage {
archive: CORRETTO_EXT.1,
download_url,
java_version: vec![this_major],
name,
vendor: JavaVendor::Corretto,
checksum: None,
size: None,
}
.with_checksum(checksum),
);
}
packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0)));

View file

@ -3,6 +3,7 @@ use serde::Deserialize;
use url::Url;
use oneclient_net::RequestClient;
use polyio::Checksum;
use crate::data::{JavaPackage, PackageArchive};
use crate::error::JavaResult;
@ -23,6 +24,11 @@ struct LibericaRelease {
#[serde(rename = "buildVersion", default)]
build_version: u32,
version: String,
/// Liberica is the one vendor that publishes SHA-1 rather than SHA-256.
#[serde(default)]
sha1: Option<String>,
#[serde(default)]
size: Option<u64>,
}
#[async_trait::async_trait]
@ -59,13 +65,18 @@ impl JavaRuntimeProvider for LibericaRuntimeProvider {
java_version.insert(0, r.feature_version);
}
let checksum = r.sha1.as_deref().map(Checksum::sha1);
JavaPackage {
archive: PackageArchive::from_filename(&r.filename),
download_url: r.download_url,
java_version,
name: r.filename,
vendor: JavaVendor::Liberica,
checksum: None,
size: r.size,
}
.with_checksum(checksum)
})
.collect();
@ -88,7 +99,8 @@ fn liberica_url(major: Option<u32>) -> JavaResult<Url> {
.append_pair("installation-type", "archive")
.append_pair(
"fields",
"downloadUrl,filename,featureVersion,updateVersion,buildVersion,version",
// `sha1` and `size` are only returned when named here.
"downloadUrl,filename,featureVersion,updateVersion,buildVersion,version,sha1,size",
)
.append_pair("output", "json");
if let Some(major) = major {

View file

@ -3,6 +3,7 @@ use serde::Deserialize;
use url::Url;
use oneclient_net::RequestClient;
use polyio::Checksum;
use crate::data::{JavaPackage, PackageArchive};
use crate::error::JavaResult;
@ -16,6 +17,12 @@ struct ZuluPackage {
download_url: String,
name: String,
java_version: Vec<u32>,
/// Only present because `include_fields` asks for it; Azul omits it from
/// the default projection.
#[serde(default)]
sha256_hash: Option<String>,
#[serde(default)]
size: Option<u64>,
}
#[async_trait::async_trait]
@ -37,13 +44,18 @@ impl JavaRuntimeProvider for ZuluRuntimeProvider {
}
fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage {
let checksum = pkg.sha256_hash.as_deref().map(Checksum::sha256);
JavaPackage {
archive: PackageArchive::Zip,
download_url: pkg.download_url,
java_version: pkg.java_version,
name: pkg.name,
vendor: JavaVendor::Zulu,
checksum: None,
size: pkg.size,
}
.with_checksum(checksum)
}
fn zulu_url(major: Option<u32>) -> JavaResult<Url> {
@ -60,6 +72,9 @@ fn zulu_url(major: Option<u32>) -> JavaResult<Url> {
.append_pair("availability_types", "CA")
.append_pair("certifications", "tck")
.append_pair("latest", "true")
// Azul's default projection omits the hash, so a download could
// only ever be verified by asking for it explicitly.
.append_pair("include_fields", "sha256_hash,size")
.append_pair("page", "1")
.append_pair("page_size", "100");
if let Some(major) = major {

View file

@ -0,0 +1,98 @@
use oneclient_java::vendors::runtime_providers;
use oneclient_net::{NetConfig, RequestClient};
use polyio::ChecksumAlgorithm;
/// A major every vendor still publishes, so a listing coming back empty means
/// the query is wrong rather than the release being retired.
const MAJOR: u32 = 21;
#[tokio::test]
#[ignore = "requires network"]
async fn every_vendor_publishes_a_usable_checksum() {
let net = RequestClient::new(NetConfig::default()).expect("a client");
let mut failures = Vec::new();
for provider in runtime_providers() {
let vendor = provider.vendor();
let packages = match provider.list_packages(Some(MAJOR), &net).await {
Ok(packages) => packages,
Err(err) => {
failures.push(format!("{vendor}: listing failed: {err}"));
continue;
}
};
if packages.is_empty() {
failures.push(format!("{vendor}: no packages listed for major {MAJOR}"));
continue;
}
let unverifiable = packages
.iter()
.filter(|package| package.checksum.is_none())
.count();
if unverifiable > 0 {
failures.push(format!(
"{vendor}: {unverifiable}/{} packages carry no usable checksum",
packages.len()
));
continue;
}
// A well-formed hash in the wrong algorithm would fail every download,
// so assert the algorithm each vendor actually publishes.
for package in &packages {
let checksum = package.checksum.as_ref().expect("checked above");
assert!(
checksum.is_well_formed(),
"{vendor}: malformed checksum on {}: {}",
package.name,
checksum.hex
);
}
println!(
"{vendor}: {} packages, all carrying {}",
packages.len(),
packages[0]
.checksum
.as_ref()
.expect("checked above")
.algorithm
.name()
);
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[tokio::test]
#[ignore = "requires network"]
async fn liberica_publishes_sha1_and_the_rest_publish_sha256() {
let net = RequestClient::new(NetConfig::default()).expect("a client");
for provider in runtime_providers() {
let vendor = provider.vendor();
let packages = provider
.list_packages(Some(MAJOR), &net)
.await
.unwrap_or_else(|err| panic!("{vendor}: listing failed: {err}"));
let Some(checksum) = packages.first().and_then(|p| p.checksum.as_ref()) else {
panic!("{vendor}: no checksum to check the algorithm of");
};
let expected = match vendor.to_string().as_str() {
"Liberica" => ChecksumAlgorithm::Sha1,
_ => ChecksumAlgorithm::Sha256,
};
assert_eq!(
checksum.algorithm,
expected,
"{vendor} changed the algorithm it publishes"
);
}
}

View file

@ -29,14 +29,18 @@ pub async fn download_to_path(
) -> McResult<()> {
let child = progress.child(label, expected_size, category);
// Every hash in a Minecraft manifest is SHA-1, so callers here pass a bare
// string and the algorithm is implied.
let expected = expected_sha1.map(polyio::Checksum::sha1);
oneclient_net::download_verified(
requester,
events,
url,
dest,
expected_sha1,
expected.as_ref(),
expected_size,
Some(child),
Some(oneclient_net::ResponseNotifyOptions::grouped(child)),
)
.await?;

View file

@ -34,6 +34,10 @@ pub enum McError {
#[error("no matching version found")]
NoMatchingVersion,
/// Files could not be downloaded and the user chose not to launch anyway.
#[error("cancelled: {failed} file(s) could not be downloaded")]
IncompleteInstallCancelled { failed: usize },
/// A Mojang response was well-formed JSON but not what the API documents.
#[error("minecraft: {0}")]
Minecraft(String),

View file

@ -13,7 +13,7 @@ use crate::rules::validate_rules;
use crate::manifest::MetadataStore;
use oneclient_common::os_ext::OsExt;
use oneclient_common::domain::GameLoader;
use oneclient_events::{GroupedProgressSession, TaskCategory};
use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase};
use oneclient_common::paths;
use crate::McCtx;
use crate::error::{McError, McResult};
@ -55,6 +55,33 @@ fn asset_object_path(dir: &Path, name: &str, hash: &str, legacy: bool) -> PathBu
}
}
/// Whether `path` is present and as long as the manifest promised.
///
/// `exists()` and `metadata()` are the same `stat` syscall, so checking the
/// length costs nothing over the presence check it replaces — and it catches the
/// truncated files an interrupted download leaves behind, which is what nearly
/// all download corruption looks like in practice. Comparing content instead
/// would mean reading and hashing every one of ~5000 assets on every launch;
/// that belongs behind an explicit repair action, not in the launch path.
///
/// An `expected` of 0 means the manifest did not say, so presence is all there
/// is to go on.
fn matches_expected_size(path: &Path, expected: u64) -> bool {
match std::fs::metadata(path) {
Ok(meta) => expected == 0 || meta.len() == expected,
Err(_) => false,
}
}
/// The artifact size a library's manifest entry promises, or 0 when it carries
/// no artifact metadata (maven-coordinate libraries from loader manifests).
fn library_artifact_size(lib: &Library) -> u64 {
lib.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.map_or(0, |artifact| u64::from(artifact.size))
}
fn native_download_size(lib: &Library, java_arch: &str) -> u64 {
let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| {
Some((
@ -106,7 +133,7 @@ pub async fn plan_downloads(
for (name, asset) in assets_index.objects {
let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy);
if !force && path.exists() {
if !force && matches_expected_size(&path, u64::from(asset.size)) {
continue;
}
plan.asset_bytes += u64::from(asset.size);
@ -130,21 +157,21 @@ pub async fn plan_downloads(
continue;
};
if !force && lib_dir.join(&artifact_path).exists() {
if !force
&& matches_expected_size(&lib_dir.join(&artifact_path), library_artifact_size(&lib))
{
continue;
}
plan.library_bytes += lib
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
.map_or(0, |artifact| u64::from(artifact.size));
plan.library_bytes += library_artifact_size(&lib);
plan.library_bytes += native_download_size(&lib, &java_arch);
plan.libraries.push(lib);
}
if let Some((path, size)) = client {
let present = path.map(|path| path.exists()).unwrap_or(false);
let present = path
.map(|path| matches_expected_size(&path, size))
.unwrap_or(false);
if force || !present {
plan.client = true;
plan.client_bytes = size;
@ -158,6 +185,165 @@ pub async fn plan_downloads(
.map_err(Into::into)
}
/// What a hash-level sweep of the installed game files found.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct VerifyReport {
/// Files present on disk that were read and hashed.
pub checked: usize,
/// Files whose contents did not match the manifest and were removed.
pub corrupt: usize,
/// Files the manifest lists that were not on disk at all.
pub missing: usize,
}
impl VerifyReport {
/// Whether anything needs re-downloading.
#[must_use]
pub fn needs_repair(&self) -> bool {
self.corrupt > 0 || self.missing > 0
}
}
/// Reads and hashes every installed game file, deleting the ones that do not
/// match the manifest.
///
/// This is the expensive counterpart to the size check the launch path uses:
/// it reads every asset and library off disk, which is why it belongs behind an
/// explicit user action rather than in the pre-launch flow. Size catches the
/// truncation an interrupted download leaves; only hashing catches a file whose
/// bytes went bad some other way — a failing disk, a half-synced cloud folder,
/// an antivirus rewrite, or a partial file written before the launcher started
/// publishing downloads by rename.
///
/// Nothing is re-downloaded here. Corrupt files are removed so that the normal
/// download path, which already knows how to fetch and verify exactly what is
/// missing, repairs them on the next prepare.
#[tracing::instrument(skip(progress, version, assets_index), fields(version_id = %version.id), level = "debug")]
pub async fn verify_game_files(
progress: &GroupedProgressSession,
version: &VersionInfo,
assets_index: AssetsIndex,
java_arch: &str,
minecraft_updated: bool,
) -> McResult<VerifyReport> {
let legacy = version.assets == "legacy";
let asset_dir = if legacy {
paths::legacy_assets_dir()?
} else {
paths::assets_object_dir()?
};
let lib_dir = paths::libraries_dir()?;
// Assets are content-addressed by their own hash; libraries and the client
// carry theirs in the manifest.
let mut targets: Vec<(PathBuf, String)> = Vec::new();
for (name, asset) in assets_index.objects {
let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy);
targets.push((path, asset.hash));
}
for lib in &version.libraries {
if let Some(rules) = &lib.rules
&& !validate_rules(rules, java_arch, minecraft_updated)
{
continue;
}
if !lib.downloadable {
continue;
}
let Ok(artifact_path) = interfrost::utils::get_path_from_artifact(&lib.name) else {
continue;
};
// Only libraries whose manifest entry carries a hash can be checked;
// maven-coordinate entries from loader manifests publish none.
if let Some(artifact) = lib
.downloads
.as_ref()
.and_then(|downloads| downloads.artifact.as_ref())
&& !artifact.sha1.is_empty()
{
targets.push((lib_dir.join(&artifact_path), artifact.sha1.clone()));
}
}
if let Some(client) = version.downloads.get(&DownloadType::Client) {
let path = paths::versions_dir()?
.join(&version.id)
.join(format!("{}.jar", version.id));
targets.push((path, client.sha1.clone()));
}
let total = targets.len() as u64;
let child = progress.child("Verifying game files", total.max(1), TaskCategory::Assets);
child.set_phase(TaskPhase::Verifying);
let started = std::time::Instant::now();
let child_for_progress = child.clone();
// One blocking task for the whole sweep rather than one per file: hashing
// thousands of small assets is dominated by per-file overhead, and a
// spawn_blocking dispatch each would cost more than the hashing itself.
let report = tokio::task::spawn_blocking(move || {
let mut report = VerifyReport::default();
for (index, (path, expected)) in targets.iter().enumerate() {
if !path.is_file() {
report.missing += 1;
continue;
}
match polyio::sha1_file_sync(path) {
Ok(actual) if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => {
report.checked += 1;
}
Ok(actual) => {
tracing::warn!(
path = %path.display(),
%actual,
%expected,
"corrupt game file; removing so it is re-downloaded"
);
report.checked += 1;
report.corrupt += 1;
if let Err(err) = std::fs::remove_file(path) {
tracing::error!(path = %path.display(), "could not remove: {err}");
}
}
Err(err) => {
// Unreadable is as good as corrupt: the game cannot use it
// either. Removing it lets the download path replace it.
tracing::warn!(path = %path.display(), "could not hash: {err}");
report.corrupt += 1;
let _ = std::fs::remove_file(path);
}
}
// Reporting every file would emit thousands of events; the child's
// own sampling is per-call, so throttle here instead.
if index % 64 == 0 {
child_for_progress.set_progress(index as u64, Some(total.max(1)));
}
}
report
})
.await
.map_err(std::io::Error::other)?;
child.finish();
tracing::info!(
checked = report.checked,
corrupt = report.corrupt,
missing = report.missing,
elapsed_ms = started.elapsed().as_millis() as u64,
"verified game files"
);
Ok(report)
}
#[tracing::instrument(skip_all)]
pub async fn download_minecraft(
ctx: &McCtx,
@ -197,7 +383,7 @@ pub async fn download_minecraft(
assets, libraries, ..
} = plan;
tokio::try_join!(
let (failed_assets, _client, failed_libraries) = tokio::try_join!(
download_assets(
ctx,
progress,
@ -214,6 +400,8 @@ pub async fn download_minecraft(
),
)?;
confirm_incomplete_install(ctx, failed_assets, failed_libraries).await?;
tracing::info!(
version = %version.id,
elapsed_ms = started.elapsed().as_millis() as u64,
@ -223,6 +411,93 @@ pub async fn download_minecraft(
Ok(())
}
/// What the user chose to do about files that could not be downloaded.
enum IncompleteInstall {
LaunchAnyway,
}
/// Asks whether to launch with files missing, when some could not be fetched.
///
/// Individual assets and libraries are downloaded concurrently and their
/// failures counted rather than propagated, so that one unreachable file does
/// not abandon the other four thousand. That count used to be logged and
/// dropped, which meant an install that had visibly failed still went on to
/// launch a game with missing textures, sounds, or a library the loader needs —
/// and the user's only clue was a crash later.
///
/// Each of these already survived every retry `oneclient_net` makes, so this is
/// not a transient blip. It is still the user's call: a
/// handful of missing assets is often survivable, and refusing to launch would
/// strand someone offline who just wants to play.
#[tracing::instrument(skip(ctx), level = "debug")]
pub async fn confirm_incomplete_install(
ctx: &McCtx,
failed_assets: usize,
failed_libraries: usize,
) -> McResult<()> {
let failed = failed_assets + failed_libraries;
if failed == 0 {
return Ok(());
}
tracing::warn!(
failed_assets,
failed_libraries,
"some files could not be downloaded; asking the user how to proceed"
);
let body = match (failed_assets, failed_libraries) {
// Libraries are on the classpath, so a missing one usually means the
// game will not start at all. Say so rather than implying it is as
// survivable as a missing sound file.
(0, libraries) => format!(
"{libraries} librar{} could not be downloaded after several attempts. \
The game will most likely fail to start without {}.",
if libraries == 1 { "y" } else { "ies" },
if libraries == 1 { "it" } else { "them" },
),
(assets, 0) => format!(
"{assets} game asset{} could not be downloaded after several attempts. \
Sounds or textures may be missing.",
if assets == 1 { "" } else { "s" },
),
(assets, libraries) => format!(
"{assets} asset(s) and {libraries} librar{} could not be downloaded after \
several attempts. The game may fail to start or be missing content.",
if libraries == 1 { "y" } else { "ies" },
),
};
let answer = ctx
.events
.ask(
Prompt::new("Some files could not be downloaded", body)
.option(
Choice::new("launch", "Launch anyway"),
IncompleteInstall::LaunchAnyway,
)
.dismiss("Cancel"),
)
.await;
match answer {
Ok(Some(chosen)) => match chosen.value {
IncompleteInstall::LaunchAnyway => {
tracing::warn!(failed, "user chose to launch with files missing");
Ok(())
}
},
// Dismissed, or the prompt could not be delivered. Both mean nobody
// agreed to launch a broken install, so the safe reading is to stop —
// a headless caller with no UI attached should not silently opt in.
Ok(None) => Err(McError::IncompleteInstallCancelled { failed }),
Err(err) => {
tracing::warn!("could not ask about the incomplete install: {err}");
Err(McError::IncompleteInstallCancelled { failed })
}
}
}
#[tracing::instrument(skip(ctx, progress), level = "debug")]
pub async fn download_version_info(
ctx: &McCtx,
@ -462,14 +737,21 @@ pub async fn download_client(
.join(&version.id)
.join(format!("{}.jar", version.id));
if path.exists()
&& !force
&& let Ok(actual) = polyio::sha1_file(&path).await
// A size check, not a hash: this runs on every launch, and re-reading the
// whole ~25 MiB jar to hash it costs more than everything else in the
// pre-launch check combined. The jar is only ever published by a rename
// after its hash matched, so a wrong-but-right-length jar is not a state the
// launcher can produce.
let expected_size = u64::from(client.size);
if !force
&& let Ok(meta) = polyio::stat(&path).await
&& (expected_size == 0 || meta.len() == expected_size)
{
if polyio::normalize_hash(&actual) == polyio::normalize_hash(&client.sha1) {
return Ok(path);
}
tracing::warn!("client hash mismatch, redownloading");
return Ok(path);
}
if path.exists() {
tracing::warn!("client jar is the wrong size, redownloading");
}
download_to_path(
@ -506,14 +788,79 @@ pub fn libraries_missing(
let Ok(rel) = interfrost::utils::get_path_from_artifact(&lib.name) else {
continue;
};
if !lib_dir.join(&rel).exists() {
tracing::warn!(library = %lib.name, "missing classpath library; will repair");
if !matches_expected_size(&lib_dir.join(&rel), library_artifact_size(lib)) {
tracing::warn!(library = %lib.name, "missing or truncated classpath library; will repair");
return Ok(true);
}
}
Ok(false)
}
/// Whether the assets tree this version needs is absent.
///
/// The index names every asset the version expects and lives inside the assets
/// directory, so checking both catches a deleted index and a deleted assets
/// tree — the second being the case users actually hit.
///
/// Takes its paths rather than resolving them, so it can be tested without the
/// process-global launcher directory.
fn assets_tree_missing(index: &Path, objects: &Path) -> bool {
if !index.is_file() {
tracing::warn!(path = %index.display(), "assets index is missing; will repair");
return true;
}
if !objects.is_dir() {
tracing::warn!(path = %objects.display(), "assets directory is missing; will repair");
return true;
}
false
}
/// Whether anything the game needs to start is absent, so the launch path can
/// repair before it tries rather than fail part-way through.
///
/// A cluster marked `Ready` skips preparation entirely on every later launch,
/// so nothing re-checks its files. Delete the assets directory out from under a
/// ready cluster and the launcher walks straight into an error naming a path
/// inside its own metadata folder — which tells the user nothing they can act
/// on. Checking here turns that into an automatic repair.
///
/// Only the coarse checks belong here: the assets tree as a whole, the client
/// jar, and the classpath libraries. Verifying the ~5000 individual asset
/// objects is the job of `download_minecraft`'s plan once a repair starts, and
/// doing it on every launch would cost far more than it saves.
#[tracing::instrument(skip(version_info), level = "debug")]
pub fn game_files_missing(
version_info: &VersionInfo,
java_arch: &str,
minecraft_updated: bool,
) -> McResult<bool> {
let index = paths::assets_index_dir()?.join(format!("{}.json", version_info.asset_index.id));
let objects = if version_info.assets == "legacy" {
paths::legacy_assets_dir()?
} else {
paths::assets_object_dir()?
};
if assets_tree_missing(&index, &objects) {
return Ok(true);
}
if let Some(client) = version_info.downloads.get(&DownloadType::Client) {
let jar = paths::versions_dir()?
.join(&version_info.id)
.join(format!("{}.jar", version_info.id));
if !matches_expected_size(&jar, u64::from(client.size)) {
tracing::warn!(path = %jar.display(), "client jar is missing or truncated; will repair");
return Ok(true);
}
}
libraries_missing(version_info, java_arch, minecraft_updated)
}
fn lib_short(name: &str) -> String {
let parts: Vec<&str> = name.split(':').collect();
match parts.as_slice() {
@ -820,3 +1167,217 @@ pub async fn get_loaders_for_version(
pub fn is_version_updated(version_index: usize, versions: &[Version]) -> bool {
version_index <= versions.iter().position(|x| x.id == "22w16a").unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"oneclient-install-{tag}-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Lays out the assets tree a ready cluster is expected to have, returning
/// the index file and objects directory that `game_files_missing` checks.
fn install_assets(dir: &Path) -> (PathBuf, PathBuf) {
let indexes = dir.join("assets").join("indexes");
let objects = dir.join("assets").join("objects");
std::fs::create_dir_all(&indexes).unwrap();
std::fs::create_dir_all(&objects).unwrap();
let index = indexes.join("17.json");
std::fs::write(&index, b"{}").unwrap();
(index, objects)
}
#[test]
fn a_complete_assets_tree_needs_no_repair() {
let dir = scratch("assets-ok");
let (index, objects) = install_assets(&dir);
assert!(!assets_tree_missing(&index, &objects));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_deleted_assets_directory_asks_for_a_repair() {
// The reported failure: a cluster marked Ready whose assets tree was
// deleted underneath it. Nothing re-checked it, so the launch walked
// into "No such file or directory" on the launcher's own metadata path.
let dir = scratch("assets-deleted");
let (index, objects) = install_assets(&dir);
std::fs::remove_dir_all(dir.join("assets")).unwrap();
assert!(assets_tree_missing(&index, &objects));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_deleted_assets_index_asks_for_a_repair() {
let dir = scratch("assets-no-index");
let (index, objects) = install_assets(&dir);
std::fs::remove_file(&index).unwrap();
assert!(assets_tree_missing(&index, &objects));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_index_that_is_a_directory_is_not_mistaken_for_a_file() {
// `create_dir_all` on a path that should hold a file is a shape the
// launcher has produced before; `is_file` has to be the check, not
// `exists`.
let dir = scratch("assets-index-dir");
let (index, objects) = install_assets(&dir);
std::fs::remove_file(&index).unwrap();
std::fs::create_dir(&index).unwrap();
assert!(assets_tree_missing(&index, &objects));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn size_check_accepts_a_complete_file() {
let dir = scratch("size-ok");
let file = dir.join("object.bin");
std::fs::write(&file, b"0123456789").unwrap();
assert!(matches_expected_size(&file, 10));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn size_check_rejects_a_truncated_file() {
let dir = scratch("size-short");
let file = dir.join("object.bin");
// What a dropped connection used to leave behind, and what every later
// launch used to skip over because the path merely existed.
std::fs::write(&file, b"0123").unwrap();
assert!(!matches_expected_size(&file, 10));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn size_check_rejects_a_missing_file() {
let dir = scratch("size-missing");
assert!(!matches_expected_size(&dir.join("nope.bin"), 10));
std::fs::remove_dir_all(&dir).unwrap();
}
/// A context whose bus is drained by the test, so `ask` has somebody to
/// talk to. No request is ever sent through the client.
fn test_ctx() -> (McCtx, tokio::sync::mpsc::UnboundedReceiver<oneclient_events::Event>) {
let (events, rx) = oneclient_events::EventBus::channel();
let net = oneclient_net::RequestClient::new(oneclient_net::NetConfig::default())
.expect("a client");
(McCtx::new(net, events), rx)
}
#[tokio::test]
async fn a_complete_install_asks_nothing() {
let (ctx, mut rx) = test_ctx();
confirm_incomplete_install(&ctx, 0, 0)
.await
.expect("nothing failed, so nothing to confirm");
assert!(
rx.try_recv().is_err(),
"a clean install must not interrupt the user"
);
}
#[tokio::test]
async fn launching_anyway_is_allowed() {
let (ctx, mut rx) = test_ctx();
let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 3, 0).await });
let Some(oneclient_events::Event::Notification(
oneclient_events::Notification::Prompt(request),
)) = rx.recv().await
else {
panic!("expected a prompt");
};
// Assets alone should not claim the game will fail to start.
assert!(request.body.contains("3 game assets"), "{}", request.body);
request
.reply
.send(Some(oneclient_events::Answer::new("launch")))
.unwrap();
asking.await.unwrap().expect("the user opted in");
}
#[tokio::test]
async fn dismissing_the_prompt_cancels_the_launch() {
let (ctx, mut rx) = test_ctx();
let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 0, 2).await });
let Some(oneclient_events::Event::Notification(
oneclient_events::Notification::Prompt(request),
)) = rx.recv().await
else {
panic!("expected a prompt");
};
// Missing libraries are a classpath problem, so the wording should be
// blunter than it is for assets.
assert!(
request.body.contains("fail to start"),
"{}",
request.body
);
request.reply.send(None).unwrap();
let err = asking.await.unwrap().expect_err("dismissal must cancel");
assert!(
matches!(err, McError::IncompleteInstallCancelled { failed: 2 }),
"{err:?}"
);
}
#[tokio::test]
async fn a_bus_with_nobody_listening_cancels_rather_than_launching() {
let (ctx, rx) = test_ctx();
// A headless run with no UI attached: nobody can agree to a broken
// install, so it must not proceed by default.
drop(rx);
let err = confirm_incomplete_install(&ctx, 1, 1)
.await
.expect_err("an unanswerable prompt must not launch the game");
assert!(
matches!(err, McError::IncompleteInstallCancelled { failed: 2 }),
"{err:?}"
);
}
#[test]
fn size_check_falls_back_to_presence_without_a_manifest_size() {
let dir = scratch("size-unknown");
let file = dir.join("object.bin");
std::fs::write(&file, b"anything").unwrap();
// Maven-coordinate libraries carry no artifact metadata, so presence is
// all there is to check.
assert!(matches_expected_size(&file, 0));
assert!(!matches_expected_size(&dir.join("nope.bin"), 0));
std::fs::remove_dir_all(&dir).unwrap();
}
}

View file

@ -6,13 +6,40 @@
use std::path::Path;
use oneclient_events::{EventBus, GroupedProgressChild, TaskPhase};
use polyio::{Sha1Stream, normalize_hash};
use polyio::{Checksum, ChecksumStream, normalize_hash};
use reqwest::Method;
use crate::error::RequestError;
use crate::response::{ResponseExt, ResponseNotifyOptions, ResponseOptions};
use crate::service::RequestClient;
/// How many times one file is fetched before it counts as failed.
///
/// [`RequestClient::send`] already retries what fails before the response
/// headers land; this covers what fails after, which on an unstable connection
/// is most of it. Without a retry here a single dropped chunk permanently fails
/// that one file and the install is quietly left incomplete.
const MAX_DOWNLOAD_ATTEMPTS: u32 = 3;
/// Whether fetching the same URL again has a realistic chance of working.
///
/// A transport error or a hash mismatch means the bytes arrived wrong, which is
/// exactly what a retry fixes. Everything else — a 404, a bad URL, a full disk —
/// will fail the same way every time, and retrying only multiplies the delay
/// before the user sees the real error.
fn worth_retrying(err: &RequestError) -> bool {
matches!(
err,
RequestError::ReqwestError(_) | RequestError::HashMismatch { .. }
)
}
/// Grows with each attempt, so a link that is briefly saturated gets a chance to
/// recover instead of being hit again immediately.
fn retry_delay(attempt: u32) -> std::time::Duration {
std::time::Duration::from_millis(300 * u64::from(attempt))
}
/// Whether a file already on disk matches `expected_sha1`.
///
/// Hashing an existing file is cheaper than re-downloading it, so every caller
@ -32,20 +59,26 @@ pub async fn matches_on_disk(path: &Path, expected_sha1: &str) -> bool {
}
}
/// Downloads `url` to `dest`, verifying SHA-1 from the bytes as they stream past.
/// Downloads `url` to `dest`, verifying it from the bytes as they stream past.
///
/// On a mismatch the partial file is removed, so a retry cannot mistake it for a
/// complete download. `progress` is optional: pass a child when the caller is
/// running a grouped session, `None` for a one-off.
#[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))]
/// Hashing happens on the bytes in flight, so verification costs no extra disk
/// read and a good file is never hashed twice. `dest` is only created once the
/// hash matches — a failed or corrupt attempt leaves nothing behind for a later
/// `exists()` check to trip over. Transport failures and mismatches are retried
/// up to [`MAX_DOWNLOAD_ATTEMPTS`] times before the error surfaces.
///
/// `notify` is optional: pass [`ResponseNotifyOptions::grouped`] when the caller
/// is running a grouped session, [`ResponseNotifyOptions::standalone`] for a
/// one-off with its own notification, or `None` to download silently.
#[tracing::instrument(level = "debug", skip(client, events, notify), fields(%url))]
pub async fn download_verified(
client: &RequestClient,
events: &EventBus,
url: &str,
dest: &Path,
expected_sha1: Option<&str>,
expected: Option<&Checksum>,
expected_size: u64,
progress: Option<GroupedProgressChild>,
notify: Option<ResponseNotifyOptions>,
) -> Result<(), RequestError> {
// Callers that fan out over thousands of files (assets) pre-create the tree,
// so this is usually a stat that avoids a blocking-pool round trip per file.
@ -55,6 +88,64 @@ pub async fn download_verified(
polyio::create_dir_all(parent).await?;
}
// A standalone notification needs a fixed id up front, or each retry opens a
// new entry in the UI instead of updating the one already there.
let notify = notify.map(ResponseNotifyOptions::pinned);
let mut attempt = 1;
loop {
match download_attempt(
client,
events,
url,
dest,
expected,
expected_size,
notify.as_ref(),
)
.await
{
Ok(()) => break,
Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => {
tracing::warn!(
attempt,
%url,
"download failed, retrying: {err}"
);
tokio::time::sleep(retry_delay(attempt)).await;
attempt += 1;
}
Err(err) => return Err(err),
}
}
if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) {
child.finish();
}
Ok(())
}
/// One pass of [`download_verified`]: fetch, hash in flight, publish on match.
///
/// Split out so a retry re-enters with a fresh request and a fresh hasher. The
/// progress child is shared across attempts and reports absolute byte counts, so
/// an attempt that restarts simply rewinds its bar rather than double-counting.
#[allow(clippy::too_many_arguments)]
async fn download_attempt(
client: &RequestClient,
events: &EventBus,
url: &str,
dest: &Path,
expected: Option<&Checksum>,
expected_size: u64,
notify: Option<&ResponseNotifyOptions>,
) -> Result<(), RequestError> {
let child = notify.and_then(ResponseNotifyOptions::child);
if let Some(child) = child {
child.set_phase(TaskPhase::Downloading);
}
let request = reqwest::Request::new(Method::GET, url.parse()?);
let response = client.send(request).await?;
@ -66,11 +157,11 @@ pub async fn download_verified(
};
let options = ResponseOptions {
notify: progress.clone().map(ResponseNotifyOptions::grouped),
notify: notify.cloned(),
};
let stream = response.stream(options, events).await?;
let mut hasher = expected_sha1.map(|_| Sha1Stream::new());
let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm));
{
let stream = futures_lite::StreamExt::map(stream, |item| {
if let (Ok(chunk), Some(hasher)) = (&item, hasher.as_mut()) {
@ -79,29 +170,29 @@ pub async fn download_verified(
item
});
let stream = std::pin::pin!(stream);
// Writes to a scratch sibling and renames on success, so `dest` is either
// absent or complete — never a truncated file a later run would skip.
polyio::write_stream(dest, stream, Some(total)).await?;
}
if let (Some(expected), Some(hasher)) = (expected_sha1, hasher) {
if let Some(child) = &progress {
if let (Some(expected), Some(hasher)) = (expected, hasher) {
if let Some(child) = child {
child.set_phase(TaskPhase::Verifying);
}
let actual = hasher.finish();
if actual != normalize_hash(expected) {
if !expected.matches(&actual) {
// The bytes were wrong, so the file that was just published is wrong
// too. Remove it rather than leave it for the next `exists()` check.
let _ = polyio::remove_file(dest).await;
return Err(RequestError::HashMismatch {
source_desc: dest.display().to_string(),
expected: expected.to_string(),
expected: format!("{} {}", expected.algorithm.name(), expected.hex),
actual,
});
}
}
if let Some(child) = progress {
child.finish();
}
Ok(())
}

View file

@ -48,6 +48,28 @@ impl ResponseNotifyOptions {
self.done_label = Some(label.into());
self
}
/// The grouped child these options report against, if any. Lets a caller
/// driving the download set phases and finish the child without having to
/// keep a second copy of it alongside the options.
#[must_use]
pub fn child(&self) -> Option<&GroupedProgressChild> {
self.child.as_ref()
}
/// Fixes the standalone notification's id, so a caller that sends the same
/// request more than once keeps updating one notification.
///
/// Without this a retried download opens a fresh entry in the UI on every
/// attempt, and a flaky connection reads as several stalled installs rather
/// than one that is recovering.
#[must_use]
pub fn pinned(mut self) -> Self {
if self.standalone_label.is_some() && self.standalone_id.is_none() {
self.standalone_id = Some(Uuid::new_v4());
}
self
}
}
#[async_trait::async_trait]

View file

@ -42,6 +42,7 @@ bytes.workspace = true
# content hashing
digest.workspace = true
sha1.workspace = true
sha2.workspace = true
[target.'cfg(windows)'.dependencies]
junction.workspace = true

View file

@ -249,6 +249,19 @@ where
Ok(())
}
/// Streams `stream` into `path`, which only ever appears complete.
///
/// The bytes go to a sibling scratch file that is renamed over `path` once the
/// stream ends cleanly, so a dropped connection cannot leave a truncated file
/// sitting at the real path where every later `exists()` check would mistake it
/// for a finished download. On any error the scratch file is removed and
/// whatever was already at `path` is left untouched — a failed re-download of a
/// good file is a no-op rather than a deletion.
///
/// Unlike [`write_atomic`] this deliberately does not fsync: that would be the
/// single most expensive operation in a five-thousand-file asset download, and
/// nothing here needs to survive power loss. A crash can still leave a short
/// file behind, which the caller's size check catches on the next run.
#[tracing::instrument(
level = "debug",
skip(path, stream),
@ -264,24 +277,36 @@ where
E: From<IOError>,
{
let path = path.as_ref();
let file = tokio::fs::File::create(path).await.map_err(IOError::from)?;
let tmp = temp_sibling(path);
let mut writer = tokio::io::BufWriter::with_capacity(write_buffer_size(size_hint), file);
let write = async {
let file = tokio::fs::File::create(&tmp).await.map_err(IOError::from)?;
let mut writer = tokio::io::BufWriter::with_capacity(write_buffer_size(size_hint), file);
let mut write_result = Ok(());
while let Some(chunk_result) = futures_lite::StreamExt::next(&mut stream).await {
let chunk = chunk_result?;
while let Some(chunk_result) = futures_lite::StreamExt::next(&mut stream).await {
let chunk = chunk_result?;
tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk)
.await
.map_err(IOError::from)?;
}
write_result = tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk).await.map_err(IOError::from);
if write_result.is_err() {
break;
};
};
tokio::io::AsyncWriteExt::flush(&mut writer)
.await
.map_err(IOError::from)?;
let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer).await.map_err(IOError::from);
Ok::<_, E>(())
}
.await;
write_result?;
flush_result?;
if let Err(err) = write {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(err);
}
if let Err(err) = rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(E::from(err));
}
Ok(())
}
@ -765,6 +790,89 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// Builds a stream that yields `chunks` and then fails, standing in for a
/// connection dropped part-way through a download.
fn failing_stream(
chunks: Vec<&'static [u8]>,
) -> impl futures_lite::Stream<Item = Result<bytes::Bytes, IOError>> + Unpin + Send {
let items = chunks
.into_iter()
.map(|c| Ok(bytes::Bytes::from_static(c)))
.chain(std::iter::once(Err(IOError::IOError(
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
))));
Box::pin(futures_lite::stream::iter(items))
}
#[tokio::test]
async fn write_stream_publishes_only_a_complete_file() {
let dir = scratch("stream-ok");
let target = dir.join("object.bin");
let chunks = vec![
Ok(bytes::Bytes::from_static(b"hello ")),
Ok(bytes::Bytes::from_static(b"world")),
];
let stream = Box::pin(futures_lite::stream::iter(chunks));
write_stream::<_, IOError>(&target, stream, Some(11))
.await
.unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"hello world");
// A successful write must not leave its scratch sibling behind either.
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n != "object.bin")
.collect();
assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn write_stream_leaves_nothing_when_the_stream_dies() {
let dir = scratch("stream-drop");
let target = dir.join("object.bin");
write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64))
.await
.expect_err("a dropped stream must fail");
// The whole point: no truncated file at the real path for a later
// `exists()` check to mistake for a finished download.
assert!(!target.exists(), "left a truncated file at the destination");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() {
let dir = scratch("stream-keep");
let target = dir.join("object.bin");
std::fs::write(&target, b"known-good").unwrap();
write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64))
.await
.expect_err("a dropped stream must fail");
// A failed repair must not be worse than no repair.
assert_eq!(std::fs::read(&target).unwrap(), b"known-good");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ensure_under_accepts_a_path_inside_a_root() {
let dir = scratch("under-inside");

View file

@ -82,6 +82,111 @@ pub fn sha1_bytes(data: &[u8]) -> String {
hasher.finish()
}
/// Which digest a manifest published its hash in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChecksumAlgorithm {
Sha1,
Sha256,
}
impl ChecksumAlgorithm {
/// Length of this digest written as hex, used to tell one apart from
/// another when a manifest gives a bare string with no algorithm field.
#[must_use]
pub const fn hex_len(self) -> usize {
match self {
Self::Sha1 => 40,
Self::Sha256 => 64,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Sha1 => "SHA-1",
Self::Sha256 => "SHA-256",
}
}
}
/// A hash a manifest promised, together with the algorithm it is written in.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Checksum {
pub algorithm: ChecksumAlgorithm,
/// Always normalized, so comparing two `Checksum`s never trips on case.
pub hex: String,
}
impl Checksum {
#[must_use]
pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef<str>) -> Self {
Self {
algorithm,
hex: normalize_hash(hex.as_ref()),
}
}
#[must_use]
pub fn sha1(hex: impl AsRef<str>) -> Self {
Self::new(ChecksumAlgorithm::Sha1, hex)
}
#[must_use]
pub fn sha256(hex: impl AsRef<str>) -> Self {
Self::new(ChecksumAlgorithm::Sha256, hex)
}
/// Whether the hash is the right shape for its algorithm.
///
/// A vendor that starts returning an empty string, a placeholder, or a
/// digest in another algorithm would otherwise fail every download with a
/// mismatch that looks like corruption. Callers drop malformed checksums and
/// download unverified rather than making the runtime uninstallable.
#[must_use]
pub fn is_well_formed(&self) -> bool {
self.hex.len() == self.algorithm.hex_len()
&& self.hex.bytes().all(|b| b.is_ascii_hexdigit())
}
#[must_use]
pub fn matches(&self, actual: &str) -> bool {
self.hex == normalize_hash(actual)
}
}
/// Incremental hashing in whichever algorithm a [`Checksum`] calls for, so a
/// download is verified from the bytes as they stream past rather than by
/// re-reading the finished file.
pub enum ChecksumStream {
Sha1(sha1::Sha1),
Sha256(sha2::Sha256),
}
impl ChecksumStream {
#[must_use]
pub fn new(algorithm: ChecksumAlgorithm) -> Self {
match algorithm {
ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()),
ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()),
}
}
pub fn update(&mut self, data: &[u8]) {
match self {
Self::Sha1(hasher) => Digest::update(hasher, data),
Self::Sha256(hasher) => Digest::update(hasher, data),
}
}
#[must_use]
pub fn finish(self) -> String {
match self {
Self::Sha1(hasher) => to_hex(&hasher.finalize()),
Self::Sha256(hasher) => to_hex(&hasher.finalize()),
}
}
}
/// Canonical form for a hash read from an external manifest, so a comparison
/// against a locally computed one doesn't fail on case or stray whitespace.
#[must_use]
@ -125,4 +230,47 @@ mod tests {
fn normalize_hash_trims_and_lowercases() {
assert_eq!(normalize_hash(" A9B2 "), "a9b2");
}
#[test]
fn checksum_streams_match_their_one_shot_digests() {
let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1);
sha1.update(b"ab");
sha1.update(b"c");
assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d");
let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256);
sha256.update(b"ab");
sha256.update(b"c");
assert_eq!(
sha256.finish(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn checksum_comparison_ignores_case_and_padding() {
let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D ");
assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d"));
assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D"));
assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709"));
}
#[test]
fn a_hash_of_the_wrong_shape_is_not_well_formed() {
// What a vendor sending a placeholder, an empty field, or a digest in
// another algorithm would look like. Verifying against these would fail
// every download with what reads to the user as corruption.
assert!(!Checksum::sha256("").is_well_formed());
assert!(!Checksum::sha256("n/a").is_well_formed());
assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed());
assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed());
assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed());
assert!(
Checksum::sha256(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
)
.is_well_formed()
);
}
}