use std::path::Path; use lanspread_db::db::GameFileDescription; use tokio::{io::AsyncWriteExt, sync::Mutex}; #[derive(Debug)] pub(super) struct VersionIniBuffer { relative_path: String, bytes: Mutex>, } impl VersionIniBuffer { pub(super) fn new(desc: &GameFileDescription) -> eyre::Result { if desc.is_dir { eyre::bail!("version.ini sentinel cannot be a directory"); } let size = usize::try_from(desc.size)?; Ok(Self { relative_path: desc.relative_path.clone(), bytes: Mutex::new(vec![0; size]), }) } pub(super) fn matches(&self, relative_path: &str) -> bool { self.relative_path == relative_path } pub(super) async fn write_at(&self, offset: u64, bytes: &[u8]) -> eyre::Result<()> { let offset = usize::try_from(offset)?; let mut buffer = self.bytes.lock().await; let end = offset .checked_add(bytes.len()) .ok_or_else(|| eyre::eyre!("version.ini chunk offset overflow"))?; if end > buffer.len() { eyre::bail!( "version.ini chunk exceeds buffer: end {end}, buffer {}", buffer.len() ); } buffer[offset..end].copy_from_slice(bytes); Ok(()) } async fn snapshot(&self) -> Vec { self.bytes.lock().await.clone() } } pub(super) async fn begin_version_ini_transaction(game_root: &Path) -> eyre::Result<()> { tokio::fs::create_dir_all(game_root).await?; remove_file_if_exists(&game_root.join(".version.ini.tmp")).await?; remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?; let version_path = game_root.join("version.ini"); if tokio::fs::metadata(&version_path) .await .is_ok_and(|metadata| metadata.is_file()) { tokio::fs::rename(version_path, game_root.join(".version.ini.discarded")).await?; } Ok(()) } pub(super) async fn rollback_version_ini_transaction(game_root: &Path) { if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.tmp")).await { log::warn!( "Failed to sweep partial version.ini tmp in {}: {err}", game_root.display() ); } if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.discarded")).await { log::warn!( "Failed to sweep discarded version.ini in {}: {err}", game_root.display() ); } } pub(super) async fn commit_version_ini_buffer( game_root: &Path, buffer: &VersionIniBuffer, ) -> eyre::Result<()> { let tmp_path = game_root.join(".version.ini.tmp"); let version_path = game_root.join("version.ini"); let bytes = buffer.snapshot().await; let mut file = tokio::fs::File::create(&tmp_path).await?; file.write_all(&bytes).await?; file.sync_all().await?; drop(file); tokio::fs::rename(&tmp_path, &version_path).await?; sync_parent_dir(&version_path)?; remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?; Ok(()) } #[cfg(unix)] fn sync_parent_dir(path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::File::open(parent)?.sync_all()?; } Ok(()) } #[cfg(not(unix))] fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { Ok(()) } async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { match tokio::fs::remove_file(path).await { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err.into()), } } #[cfg(test)] mod tests { use lanspread_db::db::GameFileDescription; use super::*; use crate::test_support::TempDir; #[tokio::test] async fn version_ini_buffer_accepts_out_of_order_chunks() { let desc = GameFileDescription { game_id: "game".to_string(), relative_path: "game/version.ini".to_string(), is_dir: false, size: 8, }; let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created"); buffer .write_at(4, b"0101") .await .expect("second chunk should write"); buffer .write_at(0, b"2025") .await .expect("first chunk should write"); assert_eq!(buffer.snapshot().await, b"20250101"); } #[tokio::test] async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() { let temp = TempDir::new("lanspread-download"); let game_root = temp.path().join("game"); tokio::fs::create_dir_all(&game_root) .await .expect("game root should be created"); tokio::fs::write(game_root.join(".version.ini.discarded"), b"old") .await .expect("discarded sentinel should be written"); let desc = GameFileDescription { game_id: "game".to_string(), relative_path: "game/version.ini".to_string(), is_dir: false, size: 8, }; let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created"); buffer .write_at(0, b"20250101") .await .expect("version should be buffered"); commit_version_ini_buffer(&game_root, &buffer) .await .expect("version sentinel should commit"); assert_eq!( std::fs::read(game_root.join("version.ini")).expect("version.ini should exist"), b"20250101" ); assert!(!game_root.join(".version.ini.tmp").exists()); assert!(!game_root.join(".version.ini.discarded").exists()); } #[tokio::test] async fn begin_version_ini_transaction_parks_existing_sentinel() { let temp = TempDir::new("lanspread-download"); let game_root = temp.path().join("game"); tokio::fs::create_dir_all(&game_root) .await .expect("game root should be created"); tokio::fs::write(game_root.join("version.ini"), b"20240101") .await .expect("version sentinel should be written"); tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial") .await .expect("tmp sentinel should be written"); begin_version_ini_transaction(&game_root) .await .expect("transaction should begin"); assert!(!game_root.join("version.ini").exists()); assert!(!game_root.join(".version.ini.tmp").exists()); assert_eq!( std::fs::read(game_root.join(".version.ini.discarded")) .expect("discarded sentinel should exist"), b"20240101" ); } #[tokio::test] async fn rollback_version_ini_transaction_sweeps_transients() { let temp = TempDir::new("lanspread-download"); let game_root = temp.path().join("game"); tokio::fs::create_dir_all(&game_root) .await .expect("game root should be created"); tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial") .await .expect("tmp sentinel should be written"); tokio::fs::write(game_root.join(".version.ini.discarded"), b"old") .await .expect("discarded sentinel should be written"); rollback_version_ini_transaction(&game_root).await; assert!(!game_root.join(".version.ini.tmp").exists()); assert!(!game_root.join(".version.ini.discarded").exists()); } }