use std::{ borrow::Cow, collections::HashSet, ffi::OsStr, marker::PhantomData, path::{Path, PathBuf}, }; use constants::{LOCK_FILE_PREFIX, TEMP_FILE_PREFIX}; use just_fmt::fmt_path::fmt_path; use crate::{ error::{DataApplyError, DataReadError, DataWriteError, HandleLockError, PrecheckFailed}, rw::RWData, }; pub struct ReadOnlyAsset where RWDataType: RWData, { _data_type: PhantomData, path: PathBuf, } /// Nothing special, I'm just too lazy macro_rules! asset_from { (|$v:ident| $src:ty => $expr:expr) => { impl From<$src> for ReadOnlyAsset where RWDataType: RWData, { fn from($v: $src) -> Self { ReadOnlyAsset { _data_type: PhantomData, path: $expr, } } } }; } asset_from!(|v| PathBuf => v); asset_from!(|v| &Path => v.to_path_buf()); asset_from!(|v| String => PathBuf::from(v)); asset_from!(|v| &str => PathBuf::from(v)); impl AsRef for ReadOnlyAsset where RWDataType: RWData, { fn as_ref(&self) -> &Path { self.path.as_ref() } } impl From> for PathBuf where RWDataType: RWData, { fn from(value: ReadOnlyAsset) -> Self { value.path } } impl ReadOnlyAsset where RWDataType: RWData, { /// Read asset content from `ReadOnlyAsset` /// ```ignore /// let sheet_asset: ReadOnlyAsset = "my_sheet.sheet".into(); /// let sheet = sheet_asset.read().await.unwrap(); /// ``` pub async fn read(&self) -> Result { RWDataType::read(&self.path).await } /// Create a `Handle` from `ReadOnlyAsset` to exclusively edit this `ReadOnlyAsset` /// ```ignore /// let sheet_asset: ReadOnlyAsset = "my_sheet.sheet".into(); /// let mut sheet_handle = sheet_asset.get_handle().await.unwrap(); /// ``` pub async fn get_handle(&self) -> Result, HandleLockError> { let asset_path = &self.path; // If the asset file does not exist, cannot create an editable handle if !asset_path.exists() { return Err(HandleLockError::AssetFileNotFound(asset_path.clone())); } // Get the lock path and temp path let lock_path = self.get_lock_path()?; let temp_path = self.get_temp_path()?; // Attempt to create the lock file; success means the lock does not exist and editing is allowed match tokio::fs::OpenOptions::new() .write(true) .create_new(true) .open(&lock_path) .await { Ok(_) => { return Ok(Handle { _data_type: PhantomData, writed: false, asset_path: self.path.clone(), lock_path, temp_path, }); } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { return Err(HandleLockError::AssetLocked); } Err(e) => { return Err(HandleLockError::IoError(e)); } } } /// Get the lock file name for the current `ReadOnlyAsset` /// ``` /// # use asset_system::rw::FooData; /// # use asset_system::asset::ReadOnlyAsset; /// let foo_asset = ReadOnlyAsset::::from("my/foo.txt"); /// let lock_path = foo_asset /// .get_lock_path() /// .unwrap(); /// assert_eq!(lock_path.to_string_lossy(), "my/~foo.txt"); /// ``` pub fn get_lock_path(&self) -> Result { // Get the file name let Some(file_name) = self.path.file_name() else { return Err(HandleLockError::ReadFileNameFailed); }; let file_name_str = check_path(file_name)?; let mut edit_path = self.path.clone(); edit_path.set_file_name(format!("{}{}", LOCK_FILE_PREFIX, file_name_str)); let Ok(edit_path) = fmt_path(edit_path) else { return Err(HandleLockError::ParsePathFailed); }; Ok(edit_path) } /// Get the name of the temporary editing file for the current `ReadOnlyAsset` /// ``` /// # use asset_system::rw::FooData; /// # use asset_system::asset::ReadOnlyAsset; /// let foo_asset = ReadOnlyAsset::::from("my/foo.txt"); /// let temp_path = foo_asset /// .get_temp_path() /// .unwrap(); /// assert_eq!(temp_path.to_string_lossy(), "my/.tmp_foo.txt"); /// ``` pub fn get_temp_path(&self) -> Result { // Get the file name let Some(file_name) = self.path.file_name() else { return Err(HandleLockError::ReadFileNameFailed); }; let file_name_str = check_path(file_name)?; let mut temp_path = self.path.clone(); temp_path.set_file_name(format!("{}{}", TEMP_FILE_PREFIX, file_name_str)); let Ok(edit_path) = fmt_path(temp_path) else { return Err(HandleLockError::ParsePathFailed); }; Ok(edit_path) } } pub struct Handle where RWDataType: RWData, { _data_type: PhantomData, writed: bool, asset_path: PathBuf, lock_path: PathBuf, temp_path: PathBuf, } impl Drop for Handle where RWDataType: RWData, { fn drop(&mut self) { if self.lock_path.exists() { let _ = std::fs::remove_file(&self.lock_path); } } } impl Handle where RWDataType: RWData, { /// Write content into the `Handle`, this will not affect the associated `ReadOnlyAsset` pub async fn write(&mut self, data: RWDataType) -> Result<(), DataWriteError> { self.writed = true; RWDataType::write(data, &self.temp_path).await } /// Read the content of this `Handle`; /// if the `Handle` has never been written to, read the content of the original `ReadOnlyAsset` pub async fn read(&self) -> Result { let path = if self.writed { &self.temp_path } else { &self.asset_path }; RWDataType::read(path).await } /// Atomically write the content of the `Handle` to the `ReadOnlyAsset` pub async fn apply(self) -> Result<(), DataApplyError> { let from = &self.temp_path; let to = &self.asset_path; // Cannot perform Apply operation when the target file does not exist // Reason: Cannot apply modifications to a non-existent file, even if editing was declared if !to.exists() { return Err(DataApplyError::AssetFileNotFound(to.clone())); } if self.writed { tokio::fs::rename(&from, &to) .await .map_err(|e| DataApplyError::IoError(e))?; } Ok(()) } pub fn get_rename_operation(&self) -> (PathBuf, PathBuf) { (self.temp_path.clone(), self.asset_path.clone()) } } /// Strict Apply precheck /// /// It checks: /// 1. Whether related files exist /// 2. If the Handle has been written to, whether the written content can be found /// 3. Whether the Handle and its ReadOnlyAsset are in the same directory /// 4. Whether the ReadOnlyAsset is locked (preventing writes/renames) /// 5. Whether the Handle is locked (preventing writes/renames) /// /// > This is only necessary when you need to ensure `multiple Handles must either all succeed or all fail together` /// /// If you only need to ensure a single Handle can Apply correctly, /// use: /// ```ignore /// // ... /// let mut handle = my_asset.get_handle().await?; /// handle.write(/* Your Data */).await?; /// handle.apply().await?; /// ``` pub async fn apply_precheck(handle: &Handle) -> Result<(), PrecheckFailed> where D: RWData, { let Ok(from) = fmt_path(&handle.temp_path) else { return Err(PrecheckFailed::FormatPathFailed); }; let Ok(to) = fmt_path(&handle.asset_path) else { return Err(PrecheckFailed::FormatPathFailed); }; tokio::try_join!( check_lock_file_exist(handle), check_asset_file_exist(handle), check_temp_file_exist_when_writed(handle), check_asset_path(handle), check_handle_is_cross_directory(&from, &to), )?; Ok(()) } /// Check if all rename operations to be performed are valid pub async fn apply_precheck_rename_operations( rename_operations: &[(PathBuf, PathBuf)], ) -> Result<(), PrecheckFailed> { let mut seen = HashSet::new(); for (from, to) in rename_operations { if !seen.insert(from) { return Err(PrecheckFailed::HasSamePath); } if !seen.insert(to) { return Err(PrecheckFailed::HasSamePath); } } // This validation must be executed sequentially // Because on Unix, we need to attempt writing files to directories // If executed in parallel, conflicts may arise for (from, to) in rename_operations { #[cfg(windows)] check_temp_file_moveable_windows(from, to).await?; #[cfg(windows)] check_asset_file_writeable_windows(to).await?; #[cfg(unix)] check_temp_file_moveable_unix(from, to).await?; #[cfg(unix)] check_asset_file_writeable_unix(to).await?; } Ok(()) } async fn check_lock_file_exist(handle: &Handle) -> Result<(), PrecheckFailed> where D: RWData, { if !handle.lock_path.exists() { return Err(PrecheckFailed::LockNotFound); } Ok(()) } async fn check_asset_file_exist(handle: &Handle) -> Result<(), PrecheckFailed> where D: RWData, { if !handle.asset_path.exists() { return Err(PrecheckFailed::AssetNotFound); } Ok(()) } async fn check_temp_file_exist_when_writed(handle: &Handle) -> Result<(), PrecheckFailed> where D: RWData, { if handle.writed && !handle.temp_path.exists() { return Err(PrecheckFailed::WritedButTempNotFound); } Ok(()) } async fn check_asset_path(handle: &Handle) -> Result<(), PrecheckFailed> where D: RWData, { if let Some(file_name) = handle.asset_path.file_name() { if check_path(file_name).is_ok() { return Ok(()); } } Err(PrecheckFailed::AssetPathInvalid) } async fn check_handle_is_cross_directory( from: &PathBuf, to: &PathBuf, ) -> Result<(), PrecheckFailed> { let from_parent = from.parent(); let to_parent = to.parent(); // "Huh? You ask why we report an error when the parent directory doesn't exist?" // // Operations are not allowed in the root directory or when there is no parent directory. // 1. This indicates they are not in the correct location // (ideally there should be at least one parent directory). // 2. This makes it impossible to compare the two directories. if from_parent.is_none() || to_parent.is_none() { return Err(PrecheckFailed::HandleFileIsNoParent); } if from_parent.unwrap() != to_parent.unwrap() { return Err(PrecheckFailed::HandleIsCrossDirectory); } Ok(()) } fn check_path(file_name: &OsStr) -> Result, PrecheckFailed> { let file_name_str = file_name.to_string_lossy(); // When operating on a TEMP_FILE or LOCK_FILE, // names like `~~foo.txt` or `.tmp_.tmp_foo.txt` would be generated // This is not expected and should result in an error // Check if the file name starts with ~ if file_name_str.starts_with(LOCK_FILE_PREFIX) { return Err(PrecheckFailed::LockOnLockFile); } // Check if the file name starts with .tmp_ if file_name_str.starts_with(TEMP_FILE_PREFIX) { return Err(PrecheckFailed::TempForTempFile); } Ok(file_name_str) } #[cfg(windows)] async fn check_asset_file_writeable_windows(path: &Path) -> Result<(), PrecheckFailed> { use std::os::windows::fs::MetadataExt; let metadata = tokio::fs::metadata(path) .await .map_err(|_| PrecheckFailed::AssetNotWritable)?; if metadata.file_attributes() & 0x1 != 0 { return Err(PrecheckFailed::AssetNotWritable); } if !check_file_can_be_deleted_windows(path) { return Err(PrecheckFailed::AssetNotWritable); } Ok(()) } #[cfg(windows)] async fn check_temp_file_moveable_windows(temp: &Path, asset: &Path) -> Result<(), PrecheckFailed> { if !check_file_can_be_deleted_windows(temp) { return Err(PrecheckFailed::TempNotMoveable); } if asset.exists() && !check_file_can_be_deleted_windows(asset) { return Err(PrecheckFailed::TempNotMoveable); } Ok(()) } #[cfg(windows)] fn check_file_can_be_deleted_windows(path: &Path) -> bool { use std::os::windows::fs::OpenOptionsExt; use winapi::um::winnt::{DELETE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; std::fs::OpenOptions::new() .access_mode(DELETE) .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) .open(path) .is_ok() } #[cfg(unix)] async fn check_asset_file_writeable_unix(path: &Path) -> Result<(), PrecheckFailed> { let parent = path.parent().ok_or(PrecheckFailed::AssetPathInvalid)?; if !check_dir_writable_unix(parent) { return Err(PrecheckFailed::AssetNotWritable); } Ok(()) } #[cfg(unix)] async fn check_temp_file_moveable_unix(temp: &Path, asset: &Path) -> Result<(), PrecheckFailed> { if !temp.exists() { return Err(PrecheckFailed::TempNotMoveable); } let temp_parent = temp.parent().ok_or(PrecheckFailed::TempNotMoveable)?; if !check_dir_writable_unix(temp_parent) { return Err(PrecheckFailed::TempNotMoveable); } let asset_parent = asset.parent().ok_or(PrecheckFailed::TempNotMoveable)?; if !check_dir_writable_unix(asset_parent) { return Err(PrecheckFailed::TempNotMoveable); } Ok(()) } #[cfg(unix)] fn check_dir_writable_unix(dir: &Path) -> bool { use std::fs::OpenOptions; let test_path = dir.join(".write_test"); let result = OpenOptions::new() .create_new(true) .write(true) .open(&test_path); match result { Ok(_) => { let _ = std::fs::remove_file(test_path); true } Err(_) => false, } } #[macro_export] macro_rules! apply { // Single handle ($handle:expr $(,)?) => {{ async { // Single-handle precheck if let Err(e) = asset::asset::apply_precheck(&$handle).await { return Err(asset::error::DataApplyError::PrecheckFailed(e)); } // Direct apply $handle.apply().await } }}; // Multiple handles ($first:expr, $($rest:expr),+ $(,)?) => {{ async { // Collect rename ops let rename_ops: Vec<(std::path::PathBuf, std::path::PathBuf)> = vec![ $first.get_rename_operation(), $( $rest.get_rename_operation(), )+ ]; // Batch precheck if let Err(e) = asset_system::asset::apply_precheck_rename_operations(&rename_ops).await { return Err(asset_system::error::DataApplyError::PrecheckFailed(e)); } // Per-handle precheck if let Err(e) = asset_system::asset::apply_precheck(&$first).await { return Err(asset_system::error::DataApplyError::PrecheckFailed(e)); } $( if let Err(e) = asset_system::asset::apply_precheck(&$rest).await { return Err(asset_system::error::DataApplyError::PrecheckFailed(e)); } )+ // Apply if let Err(e) = $first.apply().await { return Err(e); } $( if let Err(e) = $rest.apply().await { return Err(e); } )+ Ok(()) } }}; }