fix: long launches caused by anti-corruption mechanism

This commit is contained in:
LynithDev 2026-09-07 00:31:08 +02:00
parent 068ea1beaa
commit f55f816342

View file

@ -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<PathBuf> = vec![game_dir.to_path_buf()];
let mut stack: Vec<PathBuf> = 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
}