From 4f184a439056d2b5dff7aa2fa1b1a73a1cbe9582 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Sat, 7 Feb 2026 18:08:42 +0800 Subject: Add asset system with file locking and atomic writes --- Cargo.lock | 83 +++++- Cargo.toml | 8 +- systems/_constants/Cargo.toml | 2 +- systems/_constants/macros/Cargo.toml | 2 +- systems/_constants/src/lib.rs | 25 +- systems/asset/Cargo.toml | 15 + systems/asset/macros/Cargo.toml | 13 + systems/asset/macros/src/lib.rs | 43 +++ systems/asset/src/asset.rs | 552 +++++++++++++++++++++++++++++++++++ systems/asset/src/error.rs | 106 +++++++ systems/asset/src/lib.rs | 6 + systems/asset/src/rw.rs | 85 ++++++ systems/asset/test/Cargo.toml | 9 + systems/asset/test/src/lib.rs | 53 ++++ 14 files changed, 969 insertions(+), 33 deletions(-) create mode 100644 systems/asset/Cargo.toml create mode 100644 systems/asset/macros/Cargo.toml create mode 100644 systems/asset/macros/src/lib.rs create mode 100644 systems/asset/src/asset.rs create mode 100644 systems/asset/src/error.rs create mode 100644 systems/asset/src/lib.rs create mode 100644 systems/asset/src/rw.rs create mode 100644 systems/asset/test/Cargo.toml create mode 100644 systems/asset/test/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b764ba5..fc566c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,36 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asset" +version = "0.1.0" +dependencies = [ + "asset_macros", + "constants", + "string_proc", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "asset_macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "string_proc", + "syn", +] + +[[package]] +name = "asset_test" +version = "0.1.0" +dependencies = [ + "asset", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -293,8 +323,19 @@ checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" name = "constants" version = "0.1.0" dependencies = [ + "constants_macros", "libc", - "macros", +] + +[[package]] +name = "constants_macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "string_proc", + "syn", ] [[package]] @@ -686,6 +727,7 @@ name = "just_enough_vcs" version = "0.0.0" dependencies = [ "action_system", + "asset", "cfg_file", "chrono", "constants", @@ -755,17 +797,6 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -[[package]] -name = "macros" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "string_proc", - "syn", -] - [[package]] name = "memchr" version = "2.7.6" @@ -1068,7 +1099,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -1456,7 +1487,7 @@ dependencies = [ "rsa", "serde", "serde_json", - "thiserror", + "thiserror 2.0.17", "tokio", "uuid", ] @@ -1470,13 +1501,33 @@ dependencies = [ "tokio", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1605,7 +1656,7 @@ dependencies = [ "sha1_hash", "string_proc", "tcp_connection", - "thiserror", + "thiserror 2.0.17", "tokio", "vcs_data", ] diff --git a/Cargo.toml b/Cargo.toml index c3498a0..68836ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,16 +35,21 @@ members = [ "utils/string_proc", + "systems/asset", + "systems/asset/macros", + "systems/asset/test", + "systems/action", "systems/action/action_macros", "systems/_constants", "systems/_constants/macros", + # LEGACY AREA "legacy_data", "legacy_data/tests", - "legacy_actions", + # LEGACY AREA "docs", @@ -86,6 +91,7 @@ tcp_connection = { path = "utils/tcp_connection" } string_proc = { path = "utils/string_proc" } # Systems +asset = { path = "systems/asset" } action_system = { path = "systems/action" } constants = { path = "systems/_constants" } diff --git a/systems/_constants/Cargo.toml b/systems/_constants/Cargo.toml index 3978c17..e83a510 100644 --- a/systems/_constants/Cargo.toml +++ b/systems/_constants/Cargo.toml @@ -4,5 +4,5 @@ edition = "2024" version.workspace = true [dependencies] -macros = { path = "macros" } +constants_macros = { path = "macros" } libc = "0.2" diff --git a/systems/_constants/macros/Cargo.toml b/systems/_constants/macros/Cargo.toml index 04ad4fc..2aec6bc 100644 --- a/systems/_constants/macros/Cargo.toml +++ b/systems/_constants/macros/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "macros" +name = "constants_macros" version = "0.1.0" edition = "2024" diff --git a/systems/_constants/src/lib.rs b/systems/_constants/src/lib.rs index d7e4f07..8be9cd8 100644 --- a/systems/_constants/src/lib.rs +++ b/systems/_constants/src/lib.rs @@ -5,11 +5,14 @@ macro_rules! c { }; } +pub const TEMP_FILE_PREFIX: &str = ".tmp_"; +pub const LOCK_FILE_PREFIX: &str = "~"; + /// File and directory path constants for the server root #[allow(unused)] pub mod server { /// File constants - #[macros::constants("server_file")] + #[constants_macros::constants("server_file")] pub mod files { c! { CONFIG = "./config.toml" } c! { JOIN_REQUEST_KEY = "./.temp/join_requests/{member_name}.pem" } @@ -18,7 +21,7 @@ pub mod server { } /// Directory path constants - #[macros::constants("server_dir")] + #[constants_macros::constants("server_dir")] pub mod dirs { c! { KEYS = "./key/" } c! { JOIN_REQUEST_KEYS = "./.temp/join_requests/" } @@ -31,7 +34,7 @@ pub mod server { #[allow(unused)] pub mod vault { /// File path constants - #[macros::constants("vault_file")] + #[constants_macros::constants("vault_file")] pub mod files { // ### Configs ### c! { CONFIG = "./vault.toml" } @@ -41,9 +44,6 @@ pub mod vault { // Reference sheet c! { REFSHEET = "./ref/{ref_sheet_name}.sheet" } - // Editable instance of a reference sheet, maintained by one of the vault's Hosts, written back to the sheet file after editing - c! { REFSHEET_EDIT = "./ref/~{ref_sheet_name}.sheet" } - // Member sheet backup, only used to temporarily store a member's local workspace file structure, fully controlled by the corresponding member c! { MEMBER_SHEET_BACKUP = "./_member/{member_name}/backups/{sheet_name}.sheet" } @@ -60,9 +60,6 @@ pub mod vault { // Whether an index is locked; if the file exists, the member name written inside is the current holder c! { INDEX_LOCK = "./index/{index_slice_1}/{index_slice_2}/{index_name}/LOCK" } - // Editable instance of an index's version sequence, maintained by the holder, written back to the ver file after editing - c! { INDEX_VER_EDIT = "./index/{index_slice_1}/{index_slice_2}/{index_name}/~ver" } - // Index version sequence c! { INDEX_VER = "./index/{index_slice_1}/{index_slice_2}/{index_name}/ver" } @@ -74,7 +71,7 @@ pub mod vault { } /// Directory path constants - #[macros::constants("vault_dir")] + #[constants_macros::constants("vault_dir")] pub mod dirs { c! { REFSHEETS = "./ref/" } c! { SHARES_TO_REF = "./req/{ref_sheet_name}/" } @@ -89,7 +86,7 @@ pub mod vault { #[allow(unused)] pub mod workspace { /// File path constants - #[macros::constants("workspace_file")] + #[constants_macros::constants("workspace_file")] pub mod files { // ### Config ### c! { CONFIG = "./.jv/workspace.toml" } @@ -109,7 +106,7 @@ pub mod workspace { } /// Directory path constants - #[macros::constants("workspace_dir")] + #[constants_macros::constants("workspace_dir")] pub mod dirs { c! { WORKSPACE = "./.jv" } c! { VAULT_MIRROR = "./.jv/UPSTREAM/" } @@ -123,7 +120,7 @@ pub mod workspace { #[allow(unused)] pub mod user { /// File path constants - #[macros::constants("user_file")] + #[constants_macros::constants("user_file")] pub mod files { // Upstream public key, stored after initial login, used to verify the trustworthiness of that upstream c! { UPSTREAM_PUB = "./upstreams/{upstream_addr}.pem" } @@ -138,7 +135,7 @@ pub mod user { } /// Directory path constants - #[macros::constants("user_dir")] + #[constants_macros::constants("user_dir")] pub mod dirs { c! { UPSTREAM_PUBS = "./upstreams/" } c! { PRIVATE_KEYS = "./private/" } diff --git a/systems/asset/Cargo.toml b/systems/asset/Cargo.toml new file mode 100644 index 0000000..90ca9a7 --- /dev/null +++ b/systems/asset/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "asset" +edition = "2024" +version.workspace = true + +[dependencies] +string_proc = { path = "../../utils/string_proc" } + +asset_macros = { path = "macros" } +tokio = { version = "1.48.0", features = ["full"] } +thiserror = "1.0.69" + +winapi = { version = "0.3", features = ["winnt"] } + +constants = { path = "../_constants" } diff --git a/systems/asset/macros/Cargo.toml b/systems/asset/macros/Cargo.toml new file mode 100644 index 0000000..d4142f2 --- /dev/null +++ b/systems/asset/macros/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "asset_macros" +version.workspace = true +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +string_proc = { path = "../../../utils/string_proc" } +syn = { version = "2.0", features = ["full", "extra-traits"] } +quote = "1.0" +proc-macro2 = "1.0" diff --git a/systems/asset/macros/src/lib.rs b/systems/asset/macros/src/lib.rs new file mode 100644 index 0000000..f186633 --- /dev/null +++ b/systems/asset/macros/src/lib.rs @@ -0,0 +1,43 @@ +use proc_macro::TokenStream; +use quote::quote; +use string_proc::snake_case; +use syn::parse_macro_input; + +#[proc_macro_derive(RWDataTest)] +pub fn rw_data_test_derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as syn::DeriveInput); + let name = &input.ident; + let test_mod_name = syn::Ident::new( + &format!("{}_rw_test", snake_case!(name.to_string())), + name.span(), + ); + + let expanded = quote! { + #[cfg(test)] + mod #test_mod_name { + use super::*; + use asset::rw::RWData; + + #[tokio::test] + async fn test() { + let temp_dir = std::env::current_dir().unwrap().join(".temp/"); + tokio::fs::create_dir_all(&temp_dir) + .await + .expect("Create dir failed!"); + let temp_file = temp_dir.join(stringify!(#name)); + + #name::write(#name::test_data(), &temp_file) + .await + .expect("Write test data failed!"); + + let read_data = #name::read(&temp_file) + .await + .expect("Re-read data failed!"); + + assert!(#name::verify_data(#name::test_data(), read_data)); + } + } + }; + + TokenStream::from(expanded) +} diff --git a/systems/asset/src/asset.rs b/systems/asset/src/asset.rs new file mode 100644 index 0000000..247a654 --- /dev/null +++ b/systems/asset/src/asset.rs @@ -0,0 +1,552 @@ +use std::{ + borrow::Cow, + collections::HashSet, + ffi::OsStr, + marker::PhantomData, + path::{Path, PathBuf}, +}; + +use constants::{LOCK_FILE_PREFIX, TEMP_FILE_PREFIX}; +use string_proc::format_path::format_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::rw::FooData; + /// # use asset::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) = format_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::rw::FooData; + /// # use asset::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) = format_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) = format_path(&handle.temp_path) else { + return Err(PrecheckFailed::FormatPathFailed); + }; + let Ok(to) = format_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> { + // 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 ~ + let file_name_str = file_name.to_string_lossy(); + if file_name_str.starts_with(LOCK_FILE_PREFIX) { + return Err(PrecheckFailed::LockOnLockFile); + } + + // Check if the file name starts with .tmp_ + let file_name_str = file_name.to_string_lossy(); + 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::asset::apply_precheck_rename_operations(&rename_ops).await { + return Err(asset::error::DataApplyError::PrecheckFailed(e)); + } + + // Per-handle precheck + if let Err(e) = asset::asset::apply_precheck(&$first).await { + return Err(asset::error::DataApplyError::PrecheckFailed(e)); + } + $( + if let Err(e) = asset::asset::apply_precheck(&$rest).await { + return Err(asset::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(()) + } + }}; +} diff --git a/systems/asset/src/error.rs b/systems/asset/src/error.rs new file mode 100644 index 0000000..5dc529f --- /dev/null +++ b/systems/asset/src/error.rs @@ -0,0 +1,106 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum DataReadError { + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Parse error: {0}")] + ParseError(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum DataWriteError { + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum DataApplyError { + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Asset file not found: {0}")] + AssetFileNotFound(PathBuf), + + #[error("Pre-check failed: {0}")] + PrecheckFailed(#[from] PrecheckFailed), +} + +#[derive(Debug, thiserror::Error)] +pub enum PrecheckFailed { + /// Lock file does not exist + /// Means trying to apply a modification that cannot be applied + #[error("Lock not found")] + LockNotFound, + + /// Asset file does not exist + /// Apply phase will fail due to this condition + #[error("Asset not found")] + AssetNotFound, + + // Note: !writed is allowed, + // but writed without a TEMP_FILE is not allowed + /// Handle produced a write + /// but the temporary file does not exist, indicating an abnormal write operation + #[error("Temp not found")] + WritedButTempNotFound, + + /// Asset path is invalid + /// This is not an issue that should arise from normal Handle creation flow + #[error("Asset path invalid")] + AssetPathInvalid, + + /// Temporary file cannot be moved + #[error("Temp file not moveable")] + TempNotMoveable, + + /// Asset file cannot be moved + #[error("Asset file not writable")] + AssetNotWritable, + + /// Handle is processing a cross-directory operation + /// This is not atomic + #[error("Handle is cross-directory")] + HandleIsCrossDirectory, + + /// Asset file has no parent directory + /// This is not a valid path for file operations + #[error("Asset file has no parent directory")] + HandleFileIsNoParent, + + /// A handle with the same path already exists + /// This operation will cause a conflict + #[error("Handle with same path exists")] + HasSamePath, + + #[error("Lock on lock file")] + LockOnLockFile, + + #[error("Temp file for temp file")] + TempForTempFile, + + #[error("Asset path cannot be formatted")] + FormatPathFailed, +} + +#[derive(Debug, thiserror::Error)] +pub enum HandleLockError { + #[error("Parse path failed")] + ParsePathFailed, + + #[error("Asset file not found")] + AssetFileNotFound(PathBuf), + + #[error("Read file name failed")] + ReadFileNameFailed, + + #[error("Asset locked")] + AssetLocked, + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Pre-check failed: {0}")] + PrecheckFailed(#[from] PrecheckFailed), +} diff --git a/systems/asset/src/lib.rs b/systems/asset/src/lib.rs new file mode 100644 index 0000000..ac4317f --- /dev/null +++ b/systems/asset/src/lib.rs @@ -0,0 +1,6 @@ +pub mod asset; +pub mod error; +pub mod rw; + +#[allow(unused)] +pub use asset_macros::*; diff --git a/systems/asset/src/rw.rs b/systems/asset/src/rw.rs new file mode 100644 index 0000000..784d44d --- /dev/null +++ b/systems/asset/src/rw.rs @@ -0,0 +1,85 @@ +use std::path::PathBuf; + +use crate::error::{DataReadError, DataWriteError}; + +pub trait RWData { + type DataType; + + /// Implement read logic + /// Given a path, return the specific data + fn read(path: &PathBuf) -> impl Future> + Send + Sync; + + /// Implement write logic + /// Given data and a path, write to the filesystem + fn write( + data: DataType, + path: &PathBuf, + ) -> impl Future> + Send + Sync; + + /// Provide test data + fn test_data() -> DataType; + + /// Given two sets of data, determine if they are equal + /// + /// Add RWDataTest derive to your struct to automatically generate tests + /// ```ignore + /// #[derive(RWDataTest)] + /// struct FooData; + /// ``` + fn verify_data(data_a: DataType, data_b: DataType) -> bool; +} + +#[macro_export] +macro_rules! ensure_eq { + ($a:expr, $b:expr) => { + if $a != $b { + return false; + } + }; +} + +// Test Data +pub struct FooData { + pub age: i32, + pub name: String, +} + +impl RWData for FooData { + type DataType = FooData; + + async fn read(path: &PathBuf) -> Result { + let content = tokio::fs::read_to_string(path) + .await + .map_err(|e| DataReadError::IoError(e))?; + let parts: Vec<&str> = content.split('=').collect(); + if parts.len() != 2 { + return Err(DataReadError::ParseError("Invalid format".to_string())); + } + let name = parts[0].to_string(); + let age: i32 = parts[1] + .parse() + .map_err(|_| DataReadError::ParseError("Invalid age".to_string()))?; + Ok(FooData { age, name }) + } + + async fn write(data: FooData, path: &PathBuf) -> Result<(), DataWriteError> { + let content = format!("{}={}", data.name, data.age); + tokio::fs::write(path, content) + .await + .map_err(|e| DataWriteError::IoError(e))?; + Ok(()) + } + + fn test_data() -> FooData { + FooData { + age: 24, + name: "OneOneFourFiveOneFour".to_string(), + } + } + + fn verify_data(data_a: FooData, data_b: FooData) -> bool { + crate::ensure_eq!(data_a.age, data_b.age); + crate::ensure_eq!(data_a.name, data_b.name); + return true; + } +} diff --git a/systems/asset/test/Cargo.toml b/systems/asset/test/Cargo.toml new file mode 100644 index 0000000..07c6282 --- /dev/null +++ b/systems/asset/test/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "asset_test" +version.workspace = true +edition = "2024" + +[dependencies] +asset = { path = "../" } + +tokio = { version = "1.48.0", features = ["full"] } diff --git a/systems/asset/test/src/lib.rs b/systems/asset/test/src/lib.rs new file mode 100644 index 0000000..79a840d --- /dev/null +++ b/systems/asset/test/src/lib.rs @@ -0,0 +1,53 @@ +use std::path::PathBuf; + +use asset::{ + RWDataTest, ensure_eq, + error::{DataReadError, DataWriteError}, + rw::RWData, +}; + +#[derive(RWDataTest)] +pub struct FooData { + pub age: i32, + pub name: String, +} + +impl RWData for FooData { + type DataType = FooData; + + async fn read(path: &PathBuf) -> Result { + let content = tokio::fs::read_to_string(path) + .await + .map_err(|e| DataReadError::IoError(e))?; + let parts: Vec<&str> = content.split('=').collect(); + if parts.len() != 2 { + return Err(DataReadError::ParseError("Invalid format".to_string())); + } + let name = parts[0].to_string(); + let age: i32 = parts[1] + .parse() + .map_err(|_| DataReadError::ParseError("Invalid age".to_string()))?; + Ok(FooData { age, name }) + } + + async fn write(data: FooData, path: &PathBuf) -> Result<(), DataWriteError> { + let content = format!("{}={}", data.name, data.age); + tokio::fs::write(path, content) + .await + .map_err(|e| DataWriteError::IoError(e))?; + Ok(()) + } + + fn test_data() -> FooData { + FooData { + age: 24, + name: "OneOneFourFiveOneFour".to_string(), + } + } + + fn verify_data(data_a: FooData, data_b: FooData) -> bool { + ensure_eq!(data_a.age, data_b.age); + ensure_eq!(data_a.name, data_b.name); + return true; + } +} -- cgit