diff --git a/Cargo.lock b/Cargo.lock index 7946505c..9b7fa925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5109,6 +5109,7 @@ dependencies = [ "serde", "serde_json", "sha1 0.10.6", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs new file mode 100644 index 00000000..6e6c52e4 --- /dev/null +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -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) -> 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" + ); + } + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 01c8a171..af74c911 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -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; diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index d10a837c..4bf25c2e 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -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, + 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:#}")); } } diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index b70313d9..44d2d397 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -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()) } diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 5d5219a9..f44b049f 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -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) diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index a308dc1b..98608442 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -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, diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index edf92c40..9158bd55 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -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::().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, +) -> impl IntoElement { + Button::new() + .secondary() + .child(Icon::new(IconType::AlertCircle).size(16.)) + .text(text) + .on_press(move |_| { + let target = cluster_id.read().trim().parse::().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; diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs index 28b39c5b..5e1894cd 100644 --- a/packages/oneclient_content/src/bundles/manager.rs +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -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, ) diff --git a/packages/oneclient_content/src/packages/store/download.rs b/packages/oneclient_content/src/packages/store/download.rs index 630c9552..9b87e7d9 100644 --- a/packages/oneclient_content/src/packages/store/download.rs +++ b/packages/oneclient_content/src/packages/store/download.rs @@ -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?; diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index baf838d2..5b15cfbf 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -7,7 +7,7 @@ //! is why they stay here. mod migrate; -mod prepare; +pub(crate) mod prepare; mod provision; mod unlink_legacy; diff --git a/packages/oneclient_core/src/error.rs b/packages/oneclient_core/src/error.rs index d0676701..cdc19a26 100644 --- a/packages/oneclient_core/src/error.rs +++ b/packages/oneclient_core/src/error.rs @@ -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::() + && 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 = Result; + +#[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() + ); + } +} diff --git a/packages/oneclient_core/src/game/diagnosis.rs b/packages/oneclient_core/src/game/diagnosis.rs new file mode 100644 index 00000000..7a8af08e --- /dev/null +++ b/packages/oneclient_core/src/game/diagnosis.rs @@ -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, + }, +} + +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 { + 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 { + 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>>, +} + +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 { + 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"); + } +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 2da6e4eb..b3a6458f 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -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, } /// 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, + 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 { diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 7b14b2be..41a02b55 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -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, diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs index 19ca41da..c83bc3e4 100644 --- a/packages/oneclient_core/src/game/reattach.rs +++ b/packages/oneclient_core/src/game/reattach.rs @@ -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; diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs index 976a51d4..f7a8023d 100644 --- a/packages/oneclient_core/src/game/tail.rs +++ b/packages/oneclient_core/src/game/tail.rs @@ -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, + 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!"]); diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 223a2376..bcc073eb 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -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; diff --git a/packages/oneclient_core/src/simulate.rs b/packages/oneclient_core/src/simulate.rs new file mode 100644 index 00000000..ee913954 --- /dev/null +++ b/packages/oneclient_core/src/simulate.rs @@ -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, +} + +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 { + 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 = 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 { + 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(¤t) 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 { + 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, + cluster_id: i64, + count: usize, + damage: Damage, +) -> LauncherResult { + 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 = (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"); + } +} diff --git a/packages/oneclient_core/src/verify.rs b/packages/oneclient_core/src/verify.rs new file mode 100644 index 00000000..d6b6b8dd --- /dev/null +++ b/packages/oneclient_core/src/verify.rs @@ -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, + /// 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, + cluster_id: i64, +) -> LauncherResult { + 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, + cluster_id: i64, + progress: &GroupedProgressSession, +) -> LauncherResult { + 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, + 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, + 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 { + 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." + ); + } +} diff --git a/packages/oneclient_java/src/data.rs b/packages/oneclient_java/src/data.rs index 3ee42f00..038e335e 100644 --- a/packages/oneclient_java/src/data.rs +++ b/packages/oneclient_java/src/data.rs @@ -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, 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, + /// Archive size in bytes when the vendor publishes it, for progress bars + /// that would otherwise wait on `Content-Length`. + pub size: Option, +} + +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) -> 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)] diff --git a/packages/oneclient_java/src/install.rs b/packages/oneclient_java/src/install.rs index f1c92a16..fd7f75d5 100644 --- a/packages/oneclient_java/src/install.rs +++ b/packages/oneclient_java/src/install.rs @@ -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)); diff --git a/packages/oneclient_java/src/vendors/adoptium.rs b/packages/oneclient_java/src/vendors/adoptium.rs index 0e8ff275..d92f90dc 100644 --- a/packages/oneclient_java/src/vendors/adoptium.rs +++ b/packages/oneclient_java/src/vendors/adoptium.rs @@ -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, + #[serde(default)] + size: Option, } #[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), + ); } } diff --git a/packages/oneclient_java/src/vendors/corretto.rs b/packages/oneclient_java/src/vendors/corretto.rs index 1b36dca9..dcc168d5 100644 --- a/packages/oneclient_java/src/vendors/corretto.rs +++ b/packages/oneclient_java/src/vendors/corretto.rs @@ -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))); diff --git a/packages/oneclient_java/src/vendors/liberica.rs b/packages/oneclient_java/src/vendors/liberica.rs index 29fff985..e8559735 100644 --- a/packages/oneclient_java/src/vendors/liberica.rs +++ b/packages/oneclient_java/src/vendors/liberica.rs @@ -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, + #[serde(default)] + size: Option, } #[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) -> JavaResult { .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 { diff --git a/packages/oneclient_java/src/vendors/zulu.rs b/packages/oneclient_java/src/vendors/zulu.rs index 83d72574..bfb4ea5b 100644 --- a/packages/oneclient_java/src/vendors/zulu.rs +++ b/packages/oneclient_java/src/vendors/zulu.rs @@ -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, + /// Only present because `include_fields` asks for it; Azul omits it from + /// the default projection. + #[serde(default)] + sha256_hash: Option, + #[serde(default)] + size: Option, } #[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) -> JavaResult { @@ -60,6 +72,9 @@ fn zulu_url(major: Option) -> JavaResult { .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 { diff --git a/packages/oneclient_java/tests/vendor_checksums.rs b/packages/oneclient_java/tests/vendor_checksums.rs new file mode 100644 index 00000000..54220176 --- /dev/null +++ b/packages/oneclient_java/tests/vendor_checksums.rs @@ -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" + ); + } +} diff --git a/packages/oneclient_mc/src/download.rs b/packages/oneclient_mc/src/download.rs index 0f76a636..06f28534 100644 --- a/packages/oneclient_mc/src/download.rs +++ b/packages/oneclient_mc/src/download.rs @@ -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?; diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs index b48072bc..2cb55136 100644 --- a/packages/oneclient_mc/src/error.rs +++ b/packages/oneclient_mc/src/error.rs @@ -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), diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index 1afffe67..9a1e0232 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -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 { + 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 { + 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) { + 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(); + } +} diff --git a/packages/oneclient_net/src/download.rs b/packages/oneclient_net/src/download.rs index f164fd7c..a59a2609 100644 --- a/packages/oneclient_net/src/download.rs +++ b/packages/oneclient_net/src/download.rs @@ -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, + notify: Option, ) -> 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(()) } diff --git a/packages/oneclient_net/src/response.rs b/packages/oneclient_net/src/response.rs index c45cdc39..842165a9 100644 --- a/packages/oneclient_net/src/response.rs +++ b/packages/oneclient_net/src/response.rs @@ -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] diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml index 290f4889..3c8961c0 100644 --- a/packages/polyio/Cargo.toml +++ b/packages/polyio/Cargo.toml @@ -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 diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index 2253c3c1..d48efeb5 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -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, { 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> + 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"); diff --git a/packages/polyio/src/hash.rs b/packages/polyio/src/hash.rs index f7e87859..907b17c8 100644 --- a/packages/polyio/src/hash.rs +++ b/packages/polyio/src/hash.rs @@ -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) -> Self { + Self { + algorithm, + hex: normalize_hash(hex.as_ref()), + } + } + + #[must_use] + pub fn sha1(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha1, hex) + } + + #[must_use] + pub fn sha256(hex: impl AsRef) -> 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() + ); + } }