From f55f8163429da222d7e9dcf5e3c78644f8fe096f Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:31:08 +0200 Subject: [PATCH] fix: long launches caused by anti-corruption mechanism --- packages/oneclient_core/src/game/heal.rs | 173 ++++++++++++----------- 1 file changed, 91 insertions(+), 82 deletions(-) diff --git a/packages/oneclient_core/src/game/heal.rs b/packages/oneclient_core/src/game/heal.rs index 5849d1d5..7104ef1e 100644 --- a/packages/oneclient_core/src/game/heal.rs +++ b/packages/oneclient_core/src/game/heal.rs @@ -1,35 +1,88 @@ +use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; -use tokio::io::AsyncReadExt; +use oneclient_common::domain::ContentType; -const PROBE_LEN: usize = 8 * 1024; +const PROBE_LEN: usize = 4096; + +const MAX_PROBE_BYTES: u64 = 32 * 1024 * 1024; + +const BULK_NAMES: [&[u8]; 4] = [b"cache", b"repo", b"backup", b"logs"]; + +const NEVER_PROBED: [&str; 8] = [ + "db", "sqlite", "sqlite3", "wal", "shm", "journal", "lock", "idx", +]; + +const SWEPT_DIRS: [&str; 1] = ["config"]; + +const SWEPT_CONTENT: [(ContentType, bool); 3] = [ + (ContentType::Mod, true), + (ContentType::ResourcePack, false), + (ContentType::Shader, false), +]; #[tracing::instrument(level = "debug")] pub async fn clear_zeroed_files(game_dir: &Path) -> usize { + let game_dir = game_dir.to_path_buf(); + + tokio::task::spawn_blocking(move || { + let mut cleared = sweep_dir(&game_dir, false); + + for dir in SWEPT_DIRS { + cleared += sweep_dir(&game_dir.join(dir), true); + } + + for (content_type, recurse) in SWEPT_CONTENT { + cleared += sweep_dir(&game_dir.join(content_type.folder_name()), recurse); + } + + if cleared > 0 { + tracing::info!( + cleared, + game_dir = %game_dir.display(), + "cleared zero-filled files; affected mods will regenerate defaults" + ); + } + + cleared + }) + .await + .unwrap_or(0) +} + +fn sweep_dir(root: &Path, recurse: bool) -> usize { let mut cleared = 0usize; - let mut stack: Vec = vec![game_dir.to_path_buf()]; + let mut stack: Vec = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { - let Ok(mut entries) = polyio::read_dir(&dir).await else { + let Ok(entries) = fs::read_dir(&dir) else { continue; }; - while let Ok(Some(entry)) = entries.next_entry().await { - let Ok(file_type) = entry.file_type().await else { + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { continue; }; + let path = entry.path(); + if names_bulk_data(&path) { + continue; + } + if file_type.is_dir() { - stack.push(path); + if recurse { + stack.push(path); + } continue; } - if !file_type.is_file() || !is_zeroed(&path).await { + if !file_type.is_file() || !worth_probing(&path) || !is_zeroed(&path) { continue; } - match polyio::remove_file(&path).await { + match fs::remove_file(&path) { Ok(()) => { cleared += 1; tracing::warn!( @@ -46,27 +99,39 @@ pub async fn clear_zeroed_files(game_dir: &Path) -> usize { } } - if cleared > 0 { - tracing::info!( - cleared, - game_dir = %game_dir.display(), - "cleared zero-filled files; affected mods will regenerate defaults" - ); - } - cleared } -async fn is_zeroed(path: &Path) -> bool { - let Ok(mut file) = tokio::fs::File::open(path).await else { +fn names_bulk_data(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + let name = name.as_bytes(); + + BULK_NAMES.iter().any(|needle| { + name.windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) + }) + }) +} + +fn worth_probing(path: &Path) -> bool { + !path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| NEVER_PROBED.iter().any(|skip| ext.eq_ignore_ascii_case(skip))) +} + +fn is_zeroed(path: &Path) -> bool { + let Ok(mut file) = fs::File::open(path) else { return false; }; - let mut buf = vec![0u8; PROBE_LEN]; - let mut seen_any = false; + let mut buf = [0u8; PROBE_LEN]; + let mut read_so_far = 0u64; loop { - let read = match file.read(&mut buf).await { + let read = match file.read(&mut buf) { Ok(0) => break, Ok(n) => n, Err(_) => return false, @@ -76,68 +141,12 @@ async fn is_zeroed(path: &Path) -> bool { return false; } - seen_any = true; - } + read_so_far += read as u64; - seen_any -} - -#[cfg(test)] -mod tests { - use super::*; - - fn write(path: &Path, bytes: &[u8]) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); + if read_so_far > MAX_PROBE_BYTES { + return false; } - std::fs::write(path, bytes).unwrap(); } - #[tokio::test] - async fn clears_only_non_empty_all_nul_files() { - let dir = polyio::testing::ScratchDir::new("heal-zeroed"); - let root = dir.join("game"); - - let zeroed = root.join("config").join("sodium-options.json"); - let partly = root.join("config").join("half.json"); - let good = root.join("options.txt"); - let empty = root.join(".dedicated_directory"); - let nested = root.join("config").join("deep").join("nested.toml"); - - write(&zeroed, &[0u8; 818]); - write(&partly, &[&[0u8; 4096][..], b"{}"].concat()); - write(&good, b"fov:70\n"); - write(&empty, b""); - write(&nested, &[0u8; 3]); - - assert_eq!(clear_zeroed_files(&root).await, 2); - - assert!(!zeroed.exists()); - assert!(!nested.exists(), "recursion must reach nested directories"); - assert!(partly.exists()); - assert!(good.exists()); - assert!(empty.exists(), "empty marker files are not corruption"); - } - - #[cfg(unix)] - #[tokio::test] - async fn does_not_descend_linked_directories() { - let dir = polyio::testing::ScratchDir::new("heal-symlink"); - let root = dir.join("game"); - let outside = dir.join("launcher-logs"); - - let victim = outside.join("latest.log"); - write(&victim, &[0u8; 64]); - std::fs::create_dir_all(&root).unwrap(); - std::os::unix::fs::symlink(&outside, root.join("logs")).unwrap(); - - assert_eq!(clear_zeroed_files(&root).await, 0); - assert!(victim.exists()); - } - - #[tokio::test] - async fn missing_directory_is_not_an_error() { - let dir = polyio::testing::ScratchDir::new("heal-missing"); - assert_eq!(clear_zeroed_files(&dir.join("nope")).await, 0); - } + read_so_far > 0 }