diff options
Diffstat (limited to 'crates/vcs_data/src/data')
| -rw-r--r-- | crates/vcs_data/src/data/local.rs | 100 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/local/config.rs | 53 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/member.rs | 69 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/sheet.rs | 347 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/user.rs | 28 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/user/accounts.rs | 164 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/vault.rs | 146 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/vault/config.rs | 77 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/vault/member.rs | 140 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/vault/sheets.rs | 268 | ||||
| -rw-r--r-- | crates/vcs_data/src/data/vault/virtual_file.rs | 473 |
11 files changed, 1865 insertions, 0 deletions
diff --git a/crates/vcs_data/src/data/local.rs b/crates/vcs_data/src/data/local.rs new file mode 100644 index 0000000..1c99832 --- /dev/null +++ b/crates/vcs_data/src/data/local.rs @@ -0,0 +1,100 @@ +use std::{env::current_dir, path::PathBuf}; + +use cfg_file::config::ConfigFile; +use tokio::fs; + +use crate::{ + constants::{CLIENT_FILE_README, CLIENT_FILE_WORKSPACE}, + current::{current_local_path, find_local_path}, + data::local::config::LocalConfig, +}; + +pub mod config; + +pub struct LocalWorkspace { + config: LocalConfig, + local_path: PathBuf, +} + +impl LocalWorkspace { + /// Get the path of the local workspace. + pub fn local_path(&self) -> &PathBuf { + &self.local_path + } + + /// Initialize local workspace. + pub fn init(config: LocalConfig, local_path: impl Into<PathBuf>) -> Option<Self> { + let local_path = find_local_path(local_path)?; + Some(Self { config, local_path }) + } + + /// Initialize local workspace in the current directory. + pub fn init_current_dir(config: LocalConfig) -> Option<Self> { + let local_path = current_local_path()?; + Some(Self { config, local_path }) + } + + /// Setup local workspace + pub async fn setup_local_workspace( + local_path: impl Into<PathBuf>, + ) -> Result<(), std::io::Error> { + let local_path: PathBuf = local_path.into(); + + // Ensure directory is empty + if local_path.exists() && local_path.read_dir()?.next().is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::DirectoryNotEmpty, + "DirectoryNotEmpty", + )); + } + + // 1. Setup config + let config = LocalConfig::default(); + LocalConfig::write_to(&config, local_path.join(CLIENT_FILE_WORKSPACE)).await?; + + // 2. Setup README.md + let readme_content = "\ +# JustEnoughVCS Local Workspace + +This directory is a **Local Workspace** managed by `JustEnoughVCS`. All files and subdirectories within this scope can be version-controlled using the `JustEnoughVCS` CLI or GUI tools, with the following exceptions: + +- The `.jv` directory +- Any files or directories excluded via `.jgnore` or `.gitignore` + +> ⚠️ **Warning** +> +> Files in this workspace will be uploaded to the upstream server. Please ensure you fully trust this server before proceeding. + +## Access Requirements + +To use `JustEnoughVCS` with this workspace, you must have: + +- **A registered user ID** with the upstream server +- **Your private key** properly configured locally +- **Your public key** stored in the server's public key directory + +Without these credentials, the server will reject all access requests. + +## Support + +- **Permission or access issues?** → Contact your server administrator +- **Tooling problems or bugs?** → Reach out to the development team via [GitHub Issues](https://github.com/JustEnoughVCS/VersionControl/issues) +- **Documentation**: Visit our repository for full documentation + +------ + +*Thank you for using JustEnoughVCS!* +".to_string() + .trim() + .to_string(); + fs::write(local_path.join(CLIENT_FILE_README), readme_content).await?; + + Ok(()) + } + + /// Setup local workspace in current directory + pub async fn setup_local_workspacecurrent_dir() -> Result<(), std::io::Error> { + Self::setup_local_workspace(current_dir()?).await?; + Ok(()) + } +} diff --git a/crates/vcs_data/src/data/local/config.rs b/crates/vcs_data/src/data/local/config.rs new file mode 100644 index 0000000..5444047 --- /dev/null +++ b/crates/vcs_data/src/data/local/config.rs @@ -0,0 +1,53 @@ +use cfg_file::ConfigFile; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; + +use crate::constants::CLIENT_FILE_WORKSPACE; +use crate::constants::PORT; +use crate::data::member::MemberId; + +#[derive(Serialize, Deserialize, ConfigFile)] +#[cfg_file(path = CLIENT_FILE_WORKSPACE)] +pub struct LocalConfig { + /// The upstream address, representing the upstream address of the local workspace, + /// to facilitate timely retrieval of new updates from the upstream source. + upstream_addr: SocketAddr, + + /// The member ID used by the current local workspace. + /// This ID will be used to verify access permissions when connecting to the upstream server. + using_account: MemberId, +} + +impl Default for LocalConfig { + fn default() -> Self { + Self { + upstream_addr: SocketAddr::V4(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::new(127, 0, 0, 1), + PORT, + )), + using_account: "unknown".to_string(), + } + } +} + +impl LocalConfig { + /// Set the vault address. + pub fn set_vault_addr(&mut self, addr: SocketAddr) { + self.upstream_addr = addr; + } + + /// Get the vault address. + pub fn vault_addr(&self) -> SocketAddr { + self.upstream_addr + } + + /// Set the currently used account + pub fn set_current_account(&mut self, account: MemberId) { + self.using_account = account; + } + + /// Get the currently used account + pub fn current_account(&self) -> MemberId { + self.using_account.clone() + } +} diff --git a/crates/vcs_data/src/data/member.rs b/crates/vcs_data/src/data/member.rs new file mode 100644 index 0000000..b5136a1 --- /dev/null +++ b/crates/vcs_data/src/data/member.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; + +use cfg_file::ConfigFile; +use serde::{Deserialize, Serialize}; +use string_proc::snake_case; + +pub type MemberId = String; + +#[derive(Debug, Eq, Clone, ConfigFile, Serialize, Deserialize)] +pub struct Member { + /// Member ID, the unique identifier of the member + id: String, + + /// Member metadata + metadata: HashMap<String, String>, +} + +impl Default for Member { + fn default() -> Self { + Self::new("default_user") + } +} + +impl PartialEq for Member { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl std::fmt::Display for Member { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.id) + } +} + +impl std::convert::AsRef<str> for Member { + fn as_ref(&self) -> &str { + &self.id + } +} + +impl Member { + /// Create member struct by id + pub fn new(new_id: impl Into<String>) -> Self { + Self { + id: snake_case!(new_id.into()), + metadata: HashMap::new(), + } + } + + /// Get member id + pub fn id(&self) -> String { + self.id.clone() + } + + /// Get metadata + pub fn metadata(&self, key: impl Into<String>) -> Option<&String> { + self.metadata.get(&key.into()) + } + + /// Set metadata + pub fn set_metadata( + &mut self, + key: impl AsRef<str>, + value: impl Into<String>, + ) -> Option<String> { + self.metadata.insert(key.as_ref().to_string(), value.into()) + } +} diff --git a/crates/vcs_data/src/data/sheet.rs b/crates/vcs_data/src/data/sheet.rs new file mode 100644 index 0000000..a6220c9 --- /dev/null +++ b/crates/vcs_data/src/data/sheet.rs @@ -0,0 +1,347 @@ +use std::{collections::HashMap, path::PathBuf}; + +use cfg_file::{ConfigFile, config::ConfigFile}; +use serde::{Deserialize, Serialize}; +use string_proc::simple_processer::sanitize_file_path; + +use crate::{ + constants::SERVER_FILE_SHEET, + data::{ + member::MemberId, + vault::{Vault, virtual_file::VirtualFileId}, + }, +}; + +pub type SheetName = String; +pub type SheetPathBuf = PathBuf; +pub type InputName = String; +pub type InputRelativePathBuf = PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, Eq)] +pub struct InputPackage { + /// Name of the input package + pub name: InputName, + + /// The sheet from which this input package was created + pub from: SheetName, + + /// Files in this input package with their relative paths and virtual file IDs + pub files: Vec<(InputRelativePathBuf, VirtualFileId)>, +} + +impl PartialEq for InputPackage { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +const SHEET_NAME: &str = "{sheet-name}"; + +pub struct Sheet<'a> { + /// The name of the current sheet + pub(crate) name: SheetName, + + /// Sheet data + pub(crate) data: SheetData, + + /// Sheet path + pub(crate) vault_reference: &'a Vault, +} + +#[derive(Default, Serialize, Deserialize, ConfigFile)] +pub struct SheetData { + /// The holder of the current sheet, who has full operation rights to the sheet mapping + pub(crate) holder: MemberId, + + /// Inputs + pub(crate) inputs: Vec<InputPackage>, + + /// Mapping of sheet paths to virtual file IDs + pub(crate) mapping: HashMap<SheetPathBuf, VirtualFileId>, +} + +impl<'a> Sheet<'a> { + /// Get the holder of this sheet + pub fn holder(&self) -> &MemberId { + &self.data.holder + } + + /// Get the inputs of this sheet + pub fn inputs(&self) -> &Vec<InputPackage> { + &self.data.inputs + } + + /// Get the names of the inputs of this sheet + pub fn input_names(&self) -> Vec<String> { + self.data + .inputs + .iter() + .map(|input| input.name.clone()) + .collect() + } + + /// Get the mapping of this sheet + pub fn mapping(&self) -> &HashMap<SheetPathBuf, VirtualFileId> { + &self.data.mapping + } + + /// Add an input package to the sheet + pub fn add_input(&mut self, input_package: InputPackage) -> Result<(), std::io::Error> { + if self.data.inputs.iter().any(|input| input == &input_package) { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("Input package '{}' already exists", input_package.name), + )); + } + self.data.inputs.push(input_package); + Ok(()) + } + + /// Deny and remove an input package from the sheet + pub fn deny_input(&mut self, input_name: &InputName) -> Option<InputPackage> { + self.data + .inputs + .iter() + .position(|input| input.name == *input_name) + .map(|pos| self.data.inputs.remove(pos)) + } + + /// Accept an input package and insert to the sheet + pub fn accept_import( + &mut self, + input_name: &InputName, + insert_to: &SheetPathBuf, + ) -> Result<(), std::io::Error> { + // Remove inputs + let input = self + .inputs() + .iter() + .position(|input| input.name == *input_name) + .map(|pos| self.data.inputs.remove(pos)); + + // Ensure input is not empty + let Some(input) = input else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Empty inputs.", + )); + }; + + // Insert to sheet + for (relative_path, virtual_file_id) in input.files { + let _ = self.add_mapping(insert_to.join(relative_path), virtual_file_id); + } + + Ok(()) + } + + /// Add (or Edit) a mapping entry to the sheet + /// + /// This operation performs safety checks to ensure the member has the right to add the mapping: + /// 1. If the virtual file ID doesn't exist in the vault, the mapping is added directly + /// 2. If the virtual file exists, check if the member has edit rights to the virtual file + /// 3. If member has edit rights, the mapping is not allowed to be modified and returns an error + /// 4. If member doesn't have edit rights, the mapping is allowed (member is giving up the file) + /// + /// Note: Full validation adds overhead - avoid frequent calls + pub async fn add_mapping( + &mut self, + sheet_path: SheetPathBuf, + virtual_file_id: VirtualFileId, + ) -> Result<(), std::io::Error> { + // Check if the virtual file exists in the vault + if self.vault_reference.virtual_file(&virtual_file_id).is_err() { + // Virtual file doesn't exist, add the mapping directly + self.data.mapping.insert(sheet_path, virtual_file_id); + return Ok(()); + } + + // Check if the holder has edit rights to the virtual file + match self + .vault_reference + .has_virtual_file_edit_right(self.holder(), &virtual_file_id) + .await + { + Ok(false) => { + // Holder doesn't have rights, add the mapping (member is giving up the file) + self.data.mapping.insert(sheet_path, virtual_file_id); + Ok(()) + } + Ok(true) => { + // Holder has edit rights, don't allow modifying the mapping + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Member has edit rights to the virtual file, cannot modify mapping", + )) + } + Err(_) => { + // Error checking rights, don't allow modifying the mapping + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to check virtual file edit rights", + )) + } + } + } + + /// Remove a mapping entry from the sheet + /// + /// This operation performs safety checks to ensure the member has the right to remove the mapping: + /// 1. Member must NOT have edit rights to the virtual file to release it (ensuring clear ownership) + /// 2. If the virtual file doesn't exist, the mapping is removed but no ID is returned + /// 3. If member has no edit rights and the file exists, returns the removed virtual file ID + /// + /// Note: Full validation adds overhead - avoid frequent calls + pub async fn remove_mapping(&mut self, sheet_path: &SheetPathBuf) -> Option<VirtualFileId> { + let virtual_file_id = match self.data.mapping.get(sheet_path) { + Some(id) => id, + None => { + // The mapping entry doesn't exist, nothing to remove + return None; + } + }; + + // Check if the virtual file exists in the vault + if self.vault_reference.virtual_file(virtual_file_id).is_err() { + // Virtual file doesn't exist, remove the mapping and return None + self.data.mapping.remove(sheet_path); + return None; + } + + // Check if the holder has edit rights to the virtual file + match self + .vault_reference + .has_virtual_file_edit_right(self.holder(), virtual_file_id) + .await + { + Ok(false) => { + // Holder doesn't have rights, remove and return the virtual file ID + self.data.mapping.remove(sheet_path) + } + Ok(true) => { + // Holder has edit rights, don't remove the mapping + None + } + Err(_) => { + // Error checking rights, don't remove the mapping + None + } + } + } + + /// Persist the sheet to disk + /// + /// Why not use a reference? + /// Because I don't want a second instance of the sheet to be kept in memory. + /// If needed, please deserialize and reload it. + pub async fn persist(self) -> Result<(), std::io::Error> { + SheetData::write_to(&self.data, self.sheet_path()).await + } + + /// Get the path to the sheet file + pub fn sheet_path(&self) -> PathBuf { + Sheet::sheet_path_with_name(self.vault_reference, &self.name) + } + + /// Get the path to the sheet file with the given name + pub fn sheet_path_with_name(vault: &Vault, name: impl AsRef<str>) -> PathBuf { + vault + .vault_path() + .join(SERVER_FILE_SHEET.replace(SHEET_NAME, name.as_ref())) + } + + /// Export files from the current sheet as an InputPackage for importing into other sheets + /// + /// This is the recommended way to create InputPackages. It takes a list of sheet paths + /// and generates an InputPackage with optimized relative paths by removing the longest + /// common prefix from all provided paths, then placing the files under a directory + /// named with the output_name. + /// + /// # Example + /// Given paths: + /// - `MyProject/Art/Character/Model/final.fbx` + /// - `MyProject/Art/Character/Texture/final.png` + /// - `MyProject/Art/Character/README.md` + /// + /// With output_name = "MyExport", the resulting package will contain: + /// - `MyExport/Model/final.fbx` + /// - `MyExport/Texture/final.png` + /// - `MyExport/README.md` + /// + /// # Arguments + /// * `output_name` - Name of the output package (will be used as the root directory) + /// * `paths` - List of sheet paths to include in the package + /// + /// # Returns + /// Returns an InputPackage containing the exported files with optimized paths, + /// or an error if paths are empty or files are not found in the sheet mapping + pub fn output_mappings( + &self, + output_name: InputName, + paths: &[SheetPathBuf], + ) -> Result<InputPackage, std::io::Error> { + let output_name = sanitize_file_path(output_name); + + // Return error for empty paths since there's no need to generate an empty package + if paths.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Cannot generate output package with empty paths", + )); + } + + // Find the longest common prefix among all paths + let common_prefix = Self::find_longest_common_prefix(paths); + + // Create output files with optimized relative paths + let files = paths + .iter() + .map(|path| { + let relative_path = path.strip_prefix(&common_prefix).unwrap_or(path); + let output_path = PathBuf::from(&output_name).join(relative_path); + + self.data + .mapping + .get(path) + .map(|vfid| (output_path, vfid.clone())) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("File not found: {:?}", path), + ) + }) + }) + .collect::<Result<Vec<_>, _>>()?; + + Ok(InputPackage { + name: output_name, + from: self.name.clone(), + files, + }) + } + + /// Helper function to find the longest common prefix among all paths + fn find_longest_common_prefix(paths: &[SheetPathBuf]) -> PathBuf { + if paths.is_empty() { + return PathBuf::new(); + } + + let first_path = &paths[0]; + let mut common_components = Vec::new(); + + for (component_idx, first_component) in first_path.components().enumerate() { + for path in paths.iter().skip(1) { + if let Some(component) = path.components().nth(component_idx) { + if component != first_component { + return common_components.into_iter().collect(); + } + } else { + return common_components.into_iter().collect(); + } + } + common_components.push(first_component); + } + + common_components.into_iter().collect() + } +} diff --git a/crates/vcs_data/src/data/user.rs b/crates/vcs_data/src/data/user.rs new file mode 100644 index 0000000..0abd098 --- /dev/null +++ b/crates/vcs_data/src/data/user.rs @@ -0,0 +1,28 @@ +use crate::current::current_doc_dir; +use std::path::PathBuf; + +pub mod accounts; + +pub struct UserDirectory { + local_path: PathBuf, +} + +impl UserDirectory { + /// Create a user ditectory struct from the current system's document directory + pub fn current_doc_dir() -> Option<Self> { + Some(UserDirectory { + local_path: current_doc_dir()?, + }) + } + + /// Create a user directory struct from a specified directory path + /// Returns None if the directory does not exist + pub fn from_path<P: Into<PathBuf>>(path: P) -> Option<Self> { + let local_path = path.into(); + if local_path.exists() { + Some(UserDirectory { local_path }) + } else { + None + } + } +} diff --git a/crates/vcs_data/src/data/user/accounts.rs b/crates/vcs_data/src/data/user/accounts.rs new file mode 100644 index 0000000..d77bc02 --- /dev/null +++ b/crates/vcs_data/src/data/user/accounts.rs @@ -0,0 +1,164 @@ +use std::{ + fs, + io::{Error, ErrorKind}, + path::PathBuf, +}; + +use cfg_file::config::ConfigFile; + +use crate::{ + constants::{USER_FILE_ACCOUNTS, USER_FILE_KEY, USER_FILE_MEMBER}, + data::{ + member::{Member, MemberId}, + user::UserDirectory, + }, +}; + +const SELF_ID: &str = "{self_id}"; + +/// Account Management +impl UserDirectory { + /// Read account from configuration file + pub async fn account(&self, id: &MemberId) -> Result<Member, std::io::Error> { + if let Some(cfg_file) = self.account_cfg(id) { + let member = Member::read_from(cfg_file).await?; + return Ok(member); + } + + Err(Error::new(ErrorKind::NotFound, "Account not found!")) + } + + /// List all account IDs in the user directory + pub fn account_ids(&self) -> Result<Vec<MemberId>, std::io::Error> { + let accounts_path = self + .local_path + .join(USER_FILE_ACCOUNTS.replace(SELF_ID, "")); + + if !accounts_path.exists() { + return Ok(Vec::new()); + } + + let mut account_ids = Vec::new(); + + for entry in fs::read_dir(accounts_path)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() + && let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) + && path.extension().and_then(|s| s.to_str()) == Some("toml") + { + // Remove the "_private" suffix from key files if present + let account_id = file_name.replace("_private", ""); + account_ids.push(account_id); + } + } + + Ok(account_ids) + } + + /// Get all accounts + /// This method will read and deserialize account information, please pay attention to performance issues + pub async fn accounts(&self) -> Result<Vec<Member>, std::io::Error> { + let mut accounts = Vec::new(); + + for account_id in self.account_ids()? { + if let Ok(account) = self.account(&account_id).await { + accounts.push(account); + } + } + + Ok(accounts) + } + + /// Update account info + pub async fn update_account(&self, member: Member) -> Result<(), std::io::Error> { + // Ensure account exist + if self.account_cfg(&member.id()).is_some() { + let account_cfg_path = self.account_cfg_path(&member.id()); + Member::write_to(&member, account_cfg_path).await?; + return Ok(()); + } + + Err(Error::new(ErrorKind::NotFound, "Account not found!")) + } + + /// Register an account to user directory + pub async fn register_account(&self, member: Member) -> Result<(), std::io::Error> { + // Ensure account not exist + if self.account_cfg(&member.id()).is_some() { + return Err(Error::new( + ErrorKind::DirectoryNotEmpty, + format!("Account `{}` already registered!", member.id()), + )); + } + + // Ensure accounts directory exists + let accounts_dir = self + .local_path + .join(USER_FILE_ACCOUNTS.replace(SELF_ID, "")); + if !accounts_dir.exists() { + fs::create_dir_all(&accounts_dir)?; + } + + // Write config file to accounts dir + let account_cfg_path = self.account_cfg_path(&member.id()); + Member::write_to(&member, account_cfg_path).await?; + + Ok(()) + } + + /// Remove account from user directory + pub fn remove_account(&self, id: &MemberId) -> Result<(), std::io::Error> { + // Remove config file if exists + if let Some(account_cfg_path) = self.account_cfg(id) { + fs::remove_file(account_cfg_path)?; + } + + // Remove private key file if exists + if let Some(private_key_path) = self.account_private_key(id) + && private_key_path.exists() + { + fs::remove_file(private_key_path)?; + } + + Ok(()) + } + + /// Try to get the account's configuration file to determine if the account exists + pub fn account_cfg(&self, id: &MemberId) -> Option<PathBuf> { + let cfg_file = self.account_cfg_path(id); + if cfg_file.exists() { + Some(cfg_file) + } else { + None + } + } + + /// Try to get the account's private key file to determine if the account has a private key + pub fn account_private_key(&self, id: &MemberId) -> Option<PathBuf> { + let key_file = self.account_private_key_path(id); + if key_file.exists() { + Some(key_file) + } else { + None + } + } + + /// Check if account has private key + pub fn has_private_key(&self, id: &MemberId) -> bool { + self.account_private_key(id).is_some() + } + + /// Get the account's configuration file path, but do not check if the file exists + pub fn account_cfg_path(&self, id: &MemberId) -> PathBuf { + self.local_path + .join(USER_FILE_MEMBER.replace(SELF_ID, id.to_string().as_str())) + } + + /// Get the account's private key file path, but do not check if the file exists + pub fn account_private_key_path(&self, id: &MemberId) -> PathBuf { + self.local_path + .join(USER_FILE_KEY.replace(SELF_ID, id.to_string().as_str())) + } +} diff --git a/crates/vcs_data/src/data/vault.rs b/crates/vcs_data/src/data/vault.rs new file mode 100644 index 0000000..5d17a81 --- /dev/null +++ b/crates/vcs_data/src/data/vault.rs @@ -0,0 +1,146 @@ +use std::{ + env::current_dir, + fs::{self, create_dir_all}, + path::PathBuf, +}; + +use cfg_file::config::ConfigFile; + +use crate::{ + constants::{ + SERVER_FILE_README, SERVER_FILE_VAULT, SERVER_PATH_MEMBER_PUB, SERVER_PATH_MEMBERS, + SERVER_PATH_SHEETS, SERVER_PATH_VF_ROOT, VAULT_HOST_NAME, + }, + current::{current_vault_path, find_vault_path}, + data::{member::Member, vault::config::VaultConfig}, +}; + +pub mod config; +pub mod member; +pub mod sheets; +pub mod virtual_file; + +pub struct Vault { + config: VaultConfig, + vault_path: PathBuf, +} + +impl Vault { + /// Get vault path + pub fn vault_path(&self) -> &PathBuf { + &self.vault_path + } + + /// Initialize vault + pub fn init(config: VaultConfig, vault_path: impl Into<PathBuf>) -> Option<Self> { + let vault_path = find_vault_path(vault_path)?; + Some(Self { config, vault_path }) + } + + /// Initialize vault + pub fn init_current_dir(config: VaultConfig) -> Option<Self> { + let vault_path = current_vault_path()?; + Some(Self { config, vault_path }) + } + + /// Setup vault + pub async fn setup_vault(vault_path: impl Into<PathBuf>) -> Result<(), std::io::Error> { + let vault_path: PathBuf = vault_path.into(); + + // Ensure directory is empty + if vault_path.exists() && vault_path.read_dir()?.next().is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::DirectoryNotEmpty, + "DirectoryNotEmpty", + )); + } + + // 1. Setup main config + let config = VaultConfig::default(); + VaultConfig::write_to(&config, vault_path.join(SERVER_FILE_VAULT)).await?; + + // 2. Setup sheets directory + create_dir_all(vault_path.join(SERVER_PATH_SHEETS))?; + + // 3. Setup key directory + create_dir_all(vault_path.join(SERVER_PATH_MEMBER_PUB))?; + + // 4. Setup member directory + create_dir_all(vault_path.join(SERVER_PATH_MEMBERS))?; + + // 5. Setup storage directory + create_dir_all(vault_path.join(SERVER_PATH_VF_ROOT))?; + + let Some(vault) = Vault::init(config, &vault_path) else { + return Err(std::io::Error::other("Failed to initialize vault")); + }; + + // 6. Create host member + vault + .register_member_to_vault(Member::new(VAULT_HOST_NAME)) + .await?; + + // 7. Setup reference sheet + vault + .create_sheet(&"ref".to_string(), &VAULT_HOST_NAME.to_string()) + .await?; + + // Final, generate README.md + let readme_content = format!( + "\ +# JustEnoughVCS Server Setup + +This directory contains the server configuration and data for `JustEnoughVCS`. + +## User Authentication +To allow users to connect to this server, place their public keys in the `{}` directory. +Each public key file should be named `{{member_id}}.pem` (e.g., `juliet.pem`), and contain the user's public key in PEM format. + +**ECDSA:** +```bash +openssl genpkey -algorithm ed25519 -out your_name_private.pem +openssl pkey -in your_name_private.pem -pubout -out your_name.pem +``` + +**RSA:** +```bash +openssl genpkey -algorithm RSA -out your_name_private.pem -pkeyopt rsa_keygen_bits:2048 +openssl pkey -in your_name_private.pem -pubout -out your_name.pem +``` + +**DSA:** +```bash +openssl genpkey -algorithm DSA -out your_name_private.pem -pkeyopt dsa_paramgen_bits:2048 +openssl pkey -in your_name_private.pem -pubout -out your_name.pem +``` + +Place only the `your_name.pem` file in the server's `./key/` directory, renamed to match the user's member ID. + +## File Storage +All version-controlled files (Virtual File) are stored in the `{}` directory. + +## License +This software is distributed under the MIT License. For complete license details, please see the main repository homepage. + +## Support +Repository: `https://github.com/JustEnoughVCS/VersionControl` +Please report any issues or questions on the GitHub issue tracker. + +## Thanks :) +Thank you for using `JustEnoughVCS!` + ", + SERVER_PATH_MEMBER_PUB, SERVER_PATH_VF_ROOT + ) + .trim() + .to_string(); + fs::write(vault_path.join(SERVER_FILE_README), readme_content)?; + + Ok(()) + } + + /// Setup vault in current directory + pub async fn setup_vault_current_dir() -> Result<(), std::io::Error> { + Self::setup_vault(current_dir()?).await?; + Ok(()) + } +} diff --git a/crates/vcs_data/src/data/vault/config.rs b/crates/vcs_data/src/data/vault/config.rs new file mode 100644 index 0000000..6eea25a --- /dev/null +++ b/crates/vcs_data/src/data/vault/config.rs @@ -0,0 +1,77 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use cfg_file::ConfigFile; +use serde::{Deserialize, Serialize}; + +use crate::constants::{PORT, SERVER_FILE_VAULT}; +use crate::data::member::{Member, MemberId}; + +#[derive(Serialize, Deserialize, ConfigFile)] +#[cfg_file(path = SERVER_FILE_VAULT)] +pub struct VaultConfig { + /// Vault name, which can be used as the project name and generally serves as a hint + vault_name: String, + + /// Vault admin id, a list of member id representing administrator identities + vault_admin_list: Vec<MemberId>, + + /// Vault server configuration, which will be loaded when connecting to the server + server_config: VaultServerConfig, +} + +#[derive(Serialize, Deserialize)] +pub struct VaultServerConfig { + /// Local IP address to bind to when the server starts + local_bind: IpAddr, + + /// TCP port to bind to when the server starts + port: u16, + + /// Whether to enable LAN discovery, allowing members on the same LAN to more easily find the upstream server + lan_discovery: bool, // TODO + + /// Authentication strength level + /// 0: Weakest - Anyone can claim any identity, fastest speed + /// 1: Basic - Any device can claim any registered identity, slightly faster + /// 2: Advanced - Uses asymmetric encryption, multiple devices can use key authentication to log in simultaneously, slightly slower + /// 3: Secure - Uses asymmetric encryption, only one device can use key for authentication at a time, much slower + /// Default is "Advanced", if using a lower security policy, ensure your server is only accessible by trusted devices + auth_strength: u8, // TODO +} + +impl Default for VaultConfig { + fn default() -> Self { + Self { + vault_name: "JustEnoughVault".to_string(), + vault_admin_list: Vec::new(), + server_config: VaultServerConfig { + local_bind: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + port: PORT, + lan_discovery: false, + auth_strength: 2, + }, + } + } +} + +/// Vault Management +impl VaultConfig { + // Change name of the vault. + pub fn change_name(&mut self, name: impl Into<String>) { + self.vault_name = name.into() + } + + // Add admin + pub fn add_admin(&mut self, member: &Member) { + let uuid = member.id(); + if !self.vault_admin_list.contains(&uuid) { + self.vault_admin_list.push(uuid); + } + } + + // Remove admin + pub fn remove_admin(&mut self, member: &Member) { + let id = member.id(); + self.vault_admin_list.retain(|x| x != &id); + } +} diff --git a/crates/vcs_data/src/data/vault/member.rs b/crates/vcs_data/src/data/vault/member.rs new file mode 100644 index 0000000..aebd92d --- /dev/null +++ b/crates/vcs_data/src/data/vault/member.rs @@ -0,0 +1,140 @@ +use std::{ + fs, + io::{Error, ErrorKind}, + path::PathBuf, +}; + +use cfg_file::config::ConfigFile; + +use crate::{ + constants::{SERVER_FILE_MEMBER_INFO, SERVER_FILE_MEMBER_PUB, SERVER_PATH_MEMBERS}, + data::{ + member::{Member, MemberId}, + vault::Vault, + }, +}; + +const ID_PARAM: &str = "{member_id}"; + +/// Member Manage +impl Vault { + /// Read member from configuration file + pub async fn member(&self, id: &MemberId) -> Result<Member, std::io::Error> { + if let Some(cfg_file) = self.member_cfg(id) { + let member = Member::read_from(cfg_file).await?; + return Ok(member); + } + + Err(Error::new(ErrorKind::NotFound, "Member not found!")) + } + + /// List all member IDs in the vault + pub fn member_ids(&self) -> Result<Vec<MemberId>, std::io::Error> { + let members_path = self.vault_path.join(SERVER_PATH_MEMBERS); + + if !members_path.exists() { + return Ok(Vec::new()); + } + + let mut member_ids = Vec::new(); + + for entry in fs::read_dir(members_path)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() + && let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) + && path.extension().and_then(|s| s.to_str()) == Some("toml") + { + member_ids.push(file_name.to_string()); + } + } + + Ok(member_ids) + } + + /// Get all members + /// This method will read and deserialize member information, please pay attention to performance issues + pub async fn members(&self) -> Result<Vec<Member>, std::io::Error> { + let mut members = Vec::new(); + + for member_id in self.member_ids()? { + if let Ok(member) = self.member(&member_id).await { + members.push(member); + } + } + + Ok(members) + } + + /// Update member info + pub async fn update_member(&self, member: Member) -> Result<(), std::io::Error> { + // Ensure member exist + if self.member_cfg(&member.id()).is_some() { + let member_cfg_path = self.member_cfg_path(&member.id()); + Member::write_to(&member, member_cfg_path).await?; + return Ok(()); + } + + Err(Error::new(ErrorKind::NotFound, "Member not found!")) + } + + /// Register a member to vault + pub async fn register_member_to_vault(&self, member: Member) -> Result<(), std::io::Error> { + // Ensure member not exist + if self.member_cfg(&member.id()).is_some() { + return Err(Error::new( + ErrorKind::DirectoryNotEmpty, + format!("Member `{}` already registered!", member.id()), + )); + } + + // Wrtie config file to member dir + let member_cfg_path = self.member_cfg_path(&member.id()); + Member::write_to(&member, member_cfg_path).await?; + + Ok(()) + } + + /// Remove member from vault + pub fn remove_member_from_vault(&self, id: &MemberId) -> Result<(), std::io::Error> { + // Ensure member exist + if let Some(member_cfg_path) = self.member_cfg(id) { + fs::remove_file(member_cfg_path)?; + } + + Ok(()) + } + + /// Try to get the member's configuration file to determine if the member exists + pub fn member_cfg(&self, id: &MemberId) -> Option<PathBuf> { + let cfg_file = self.member_cfg_path(id); + if cfg_file.exists() { + Some(cfg_file) + } else { + None + } + } + + /// Try to get the member's public key file to determine if the member has login permission + pub fn member_key(&self, id: &MemberId) -> Option<PathBuf> { + let key_file = self.member_key_path(id); + if key_file.exists() { + Some(key_file) + } else { + None + } + } + + /// Get the member's configuration file path, but do not check if the file exists + pub fn member_cfg_path(&self, id: &MemberId) -> PathBuf { + self.vault_path + .join(SERVER_FILE_MEMBER_INFO.replace(ID_PARAM, id.to_string().as_str())) + } + + /// Get the member's public key file path, but do not check if the file exists + pub fn member_key_path(&self, id: &MemberId) -> PathBuf { + self.vault_path + .join(SERVER_FILE_MEMBER_PUB.replace(ID_PARAM, id.to_string().as_str())) + } +} diff --git a/crates/vcs_data/src/data/vault/sheets.rs b/crates/vcs_data/src/data/vault/sheets.rs new file mode 100644 index 0000000..0bba4f5 --- /dev/null +++ b/crates/vcs_data/src/data/vault/sheets.rs @@ -0,0 +1,268 @@ +use std::{collections::HashMap, io::Error}; + +use cfg_file::config::ConfigFile; +use string_proc::snake_case; +use tokio::fs; + +use crate::{ + constants::SERVER_PATH_SHEETS, + data::{ + member::MemberId, + sheet::{Sheet, SheetData, SheetName}, + vault::Vault, + }, +}; + +/// Vault Sheets Management +impl Vault { + /// Load all sheets in the vault + /// + /// It is generally not recommended to call this function frequently. + /// Although a vault typically won't contain too many sheets, + /// if individual sheet contents are large, this operation may cause + /// significant performance bottlenecks. + pub async fn sheets<'a>(&'a self) -> Result<Vec<Sheet<'a>>, std::io::Error> { + let sheet_names = self.sheet_names()?; + let mut sheets = Vec::new(); + + for sheet_name in sheet_names { + let sheet = self.sheet(&sheet_name).await?; + sheets.push(sheet); + } + + Ok(sheets) + } + + /// Search for all sheet names in the vault + /// + /// The complexity of this operation is proportional to the number of sheets, + /// but generally there won't be too many sheets in a Vault + pub fn sheet_names(&self) -> Result<Vec<SheetName>, std::io::Error> { + // Get the sheets directory path + let sheets_dir = self.vault_path.join(SERVER_PATH_SHEETS); + + // If the directory doesn't exist, return an empty list + if !sheets_dir.exists() { + return Ok(vec![]); + } + + let mut sheet_names = Vec::new(); + + // Iterate through all files in the sheets directory + for entry in std::fs::read_dir(sheets_dir)? { + let entry = entry?; + let path = entry.path(); + + // Check if it's a YAML file + if path.is_file() + && path.extension().is_some_and(|ext| ext == "yaml") + && let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) + { + // Create a new SheetName and add it to the result list + sheet_names.push(file_stem.to_string()); + } + } + + Ok(sheet_names) + } + + /// Read a sheet from its name + /// + /// If the sheet information is successfully found in the vault, + /// it will be deserialized and read as a sheet. + /// This is the only correct way to obtain a sheet instance. + pub async fn sheet<'a>(&'a self, sheet_name: &SheetName) -> Result<Sheet<'a>, std::io::Error> { + let sheet_name = snake_case!(sheet_name.clone()); + + // Get the path to the sheet file + let sheet_path = Sheet::sheet_path_with_name(self, &sheet_name); + + // Ensure the sheet file exists + if !sheet_path.exists() { + // If the sheet does not exist, try to restore it from the trash + if self.restore_sheet(&sheet_name).await.is_err() { + // If restoration fails, return an error + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Sheet `{}` not found!", sheet_name), + )); + } + } + + // Read the sheet data from the file + let data = SheetData::read_from(sheet_path).await?; + + Ok(Sheet { + name: sheet_name.clone(), + data, + vault_reference: self, + }) + } + + /// Create a sheet locally and return the sheet instance + /// + /// This method creates a new sheet in the vault with the given name and holder. + /// It will verify that the member exists and that the sheet doesn't already exist + /// before creating the sheet file with default empty data. + pub async fn create_sheet<'a>( + &'a self, + sheet_name: &SheetName, + holder: &MemberId, + ) -> Result<Sheet<'a>, std::io::Error> { + let sheet_name = snake_case!(sheet_name.clone()); + + // Ensure member exists + if !self.member_cfg_path(holder).exists() { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Member `{}` not found!", &holder), + )); + } + + // Ensure sheet does not already exist + let sheet_file_path = Sheet::sheet_path_with_name(self, &sheet_name); + if sheet_file_path.exists() { + return Err(Error::new( + std::io::ErrorKind::AlreadyExists, + format!("Sheet `{}` already exists!", &sheet_name), + )); + } + + // Create the sheet file + let sheet_data = SheetData { + holder: holder.clone(), + inputs: Vec::new(), + mapping: HashMap::new(), + }; + SheetData::write_to(&sheet_data, sheet_file_path).await?; + + Ok(Sheet { + name: sheet_name, + data: sheet_data, + vault_reference: self, + }) + } + + /// Delete the sheet file from local disk by name + /// + /// This method will remove the sheet file with the given name from the vault. + /// It will verify that the sheet exists before attempting to delete it. + /// If the sheet is successfully deleted, it will return Ok(()). + /// + /// Warning: This operation is dangerous. Deleting a sheet will cause local workspaces + /// using this sheet to become invalid. Please ensure the sheet is not currently in use + /// and will not be used in the future. + /// + /// For a safer deletion method, consider using `delete_sheet_safety`. + /// + /// Note: This function is intended for server-side use only and should not be + /// arbitrarily called by other members to prevent unauthorized data deletion. + pub async fn delete_sheet(&self, sheet_name: &SheetName) -> Result<(), std::io::Error> { + let sheet_name = snake_case!(sheet_name.clone()); + + // Ensure sheet exists + let sheet_file_path = Sheet::sheet_path_with_name(self, &sheet_name); + if !sheet_file_path.exists() { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Sheet `{}` not found!", &sheet_name), + )); + } + + // Delete the sheet file + fs::remove_file(sheet_file_path).await?; + + Ok(()) + } + + /// Safely delete the sheet + /// + /// The sheet will be moved to the trash directory, ensuring it does not appear in the + /// results of `sheets` and `sheet_names` methods. + /// However, if the sheet's holder attempts to access the sheet through the `sheet` method, + /// the system will automatically restore it from the trash directory. + /// This means: the sheet will only permanently remain in the trash directory, + /// waiting for manual cleanup by an administrator, when it is truly no longer in use. + /// + /// This is a safer deletion method because it provides the possibility of recovery, + /// avoiding irreversible data loss caused by accidental deletion. + /// + /// Note: This function is intended for server-side use only and should not be + /// arbitrarily called by other members to prevent unauthorized data deletion. + pub async fn delete_sheet_safely(&self, sheet_name: &SheetName) -> Result<(), std::io::Error> { + let sheet_name = snake_case!(sheet_name.clone()); + + // Ensure the sheet exists + let sheet_file_path = Sheet::sheet_path_with_name(self, &sheet_name); + if !sheet_file_path.exists() { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Sheet `{}` not found!", &sheet_name), + )); + } + + // Create the trash directory + let trash_dir = self.vault_path.join(".trash"); + if !trash_dir.exists() { + fs::create_dir_all(&trash_dir).await?; + } + + // Generate a unique filename in the trash + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + let trash_file_name = format!("{}_{}.yaml", sheet_name, timestamp); + let trash_path = trash_dir.join(trash_file_name); + + // Move the sheet file to the trash + fs::rename(&sheet_file_path, &trash_path).await?; + + Ok(()) + } + + /// Restore the sheet from the trash + /// + /// Restore the specified sheet from the trash to its original location, making it accessible normally. + pub async fn restore_sheet(&self, sheet_name: &SheetName) -> Result<(), std::io::Error> { + let sheet_name = snake_case!(sheet_name.clone()); + + // Search for matching files in the trash + let trash_dir = self.vault_path.join(".trash"); + if !trash_dir.exists() { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Trash directory does not exist!".to_string(), + )); + } + + let mut found_path = None; + for entry in std::fs::read_dir(&trash_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() + && let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) + { + // Check if the filename starts with the sheet name + if file_name.starts_with(&sheet_name) { + found_path = Some(path); + break; + } + } + } + + let trash_path = found_path.ok_or_else(|| { + Error::new( + std::io::ErrorKind::NotFound, + format!("Sheet `{}` not found in trash!", &sheet_name), + ) + })?; + + // Restore the sheet to its original location + let original_path = Sheet::sheet_path_with_name(self, &sheet_name); + fs::rename(&trash_path, &original_path).await?; + + Ok(()) + } +} diff --git a/crates/vcs_data/src/data/vault/virtual_file.rs b/crates/vcs_data/src/data/vault/virtual_file.rs new file mode 100644 index 0000000..fe83594 --- /dev/null +++ b/crates/vcs_data/src/data/vault/virtual_file.rs @@ -0,0 +1,473 @@ +use std::{ + collections::HashMap, + io::{Error, ErrorKind}, + path::PathBuf, +}; + +use cfg_file::{ConfigFile, config::ConfigFile}; +use serde::{Deserialize, Serialize}; +use string_proc::snake_case; +use tcp_connection::instance::ConnectionInstance; +use tokio::fs; +use uuid::Uuid; + +use crate::{ + constants::{ + SERVER_FILE_VF_META, SERVER_FILE_VF_VERSION_INSTANCE, SERVER_PATH_VF_ROOT, + SERVER_PATH_VF_STORAGE, SERVER_PATH_VF_TEMP, + }, + data::{member::MemberId, vault::Vault}, +}; + +pub type VirtualFileId = String; +pub type VirtualFileVersion = String; + +const VF_PREFIX: &str = "vf_"; +const ID_PARAM: &str = "{vf_id}"; +const ID_INDEX: &str = "{vf_index}"; +const VERSION_PARAM: &str = "{vf_version}"; +const TEMP_NAME: &str = "{temp_name}"; + +pub struct VirtualFile<'a> { + /// Unique identifier for the virtual file + id: VirtualFileId, + + /// Reference of Vault + current_vault: &'a Vault, +} + +#[derive(Default, Clone, Serialize, Deserialize, ConfigFile)] +pub struct VirtualFileMeta { + /// Current version of the virtual file + current_version: VirtualFileVersion, + + /// The member who holds the edit right of the file + hold_member: MemberId, + + /// Description of each version + version_description: HashMap<VirtualFileVersion, VirtualFileVersionDescription>, + + /// Histories + histories: Vec<VirtualFileVersion>, +} + +#[derive(Default, Clone, Serialize, Deserialize)] +pub struct VirtualFileVersionDescription { + /// The member who created this version + pub creator: MemberId, + + /// The description of this version + pub description: String, +} + +impl VirtualFileVersionDescription { + /// Create a new version description + pub fn new(creator: MemberId, description: String) -> Self { + Self { + creator, + description, + } + } +} + +/// Virtual File Operations +impl Vault { + /// Generate a temporary path for receiving + pub fn virtual_file_temp_path(&self) -> PathBuf { + let random_receive_name = format!("{}", uuid::Uuid::new_v4()); + self.vault_path + .join(SERVER_PATH_VF_TEMP.replace(TEMP_NAME, &random_receive_name)) + } + + /// Get the directory where virtual files are stored + pub fn virtual_file_storage_dir(&self) -> PathBuf { + self.vault_path().join(SERVER_PATH_VF_ROOT) + } + + /// Get the directory where a specific virtual file is stored + pub fn virtual_file_dir(&self, id: &VirtualFileId) -> Result<PathBuf, std::io::Error> { + Ok(self.vault_path().join( + SERVER_PATH_VF_STORAGE + .replace(ID_PARAM, &id.to_string()) + .replace(ID_INDEX, &Self::vf_index(id)?), + )) + } + + // Generate index path of virtual file + fn vf_index(id: &VirtualFileId) -> Result<String, std::io::Error> { + // Remove VF_PREFIX if present + let id_str = if let Some(stripped) = id.strip_prefix(VF_PREFIX) { + stripped + } else { + id + }; + + // Extract the first part before the first hyphen + let first_part = id_str.split('-').next().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid virtual file ID format: no hyphen found", + ) + })?; + + // Ensure the first part has exactly 8 characters + if first_part.len() != 8 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid virtual file ID format: first part must be 8 characters", + ))?; + } + + // Split into 2-character chunks and join with path separator + let mut path = String::new(); + for i in (0..first_part.len()).step_by(2) { + if i > 0 { + path.push('/'); + } + path.push_str(&first_part[i..i + 2]); + } + + Ok(path) + } + + /// Get the directory where a specific virtual file's metadata is stored + pub fn virtual_file_real_path( + &self, + id: &VirtualFileId, + version: &VirtualFileVersion, + ) -> PathBuf { + self.vault_path().join( + SERVER_FILE_VF_VERSION_INSTANCE + .replace(ID_PARAM, &id.to_string()) + .replace(ID_INDEX, &Self::vf_index(id).unwrap_or_default()) + .replace(VERSION_PARAM, &version.to_string()), + ) + } + + /// Get the directory where a specific virtual file's metadata is stored + pub fn virtual_file_meta_path(&self, id: &VirtualFileId) -> PathBuf { + self.vault_path().join( + SERVER_FILE_VF_META + .replace(ID_PARAM, &id.to_string()) + .replace(ID_INDEX, &Self::vf_index(id).unwrap_or_default()), + ) + } + + /// Get the virtual file with the given ID + pub fn virtual_file(&self, id: &VirtualFileId) -> Result<VirtualFile<'_>, std::io::Error> { + let dir = self.virtual_file_dir(id); + if dir?.exists() { + Ok(VirtualFile { + id: id.clone(), + current_vault: self, + }) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Cannot found virtual file!", + )) + } + } + + /// Get the meta data of the virtual file with the given ID + pub async fn virtual_file_meta( + &self, + id: &VirtualFileId, + ) -> Result<VirtualFileMeta, std::io::Error> { + let dir = self.virtual_file_meta_path(id); + let metadata = VirtualFileMeta::read_from(dir).await?; + Ok(metadata) + } + + /// Write the meta data of the virtual file with the given ID + pub async fn write_virtual_file_meta( + &self, + id: &VirtualFileId, + meta: &VirtualFileMeta, + ) -> Result<(), std::io::Error> { + let dir = self.virtual_file_meta_path(id); + VirtualFileMeta::write_to(meta, dir).await?; + Ok(()) + } + + /// Create a virtual file from a connection instance + /// + /// It's the only way to create virtual files! + /// + /// When the target machine executes `write_file`, use this function instead of `read_file`, + /// and provide the member ID of the transmitting member. + /// + /// The system will automatically receive the file and + /// create the virtual file. + pub async fn create_virtual_file_from_connection( + &self, + instance: &mut ConnectionInstance, + member_id: &MemberId, + ) -> Result<VirtualFileId, std::io::Error> { + const FIRST_VERSION: &str = "0"; + let receive_path = self.virtual_file_temp_path(); + let new_id = format!("{}{}", VF_PREFIX, Uuid::new_v4()); + let move_path = self.virtual_file_real_path(&new_id, &FIRST_VERSION.to_string()); + + match instance.read_file(receive_path.clone()).await { + Ok(_) => { + // Read successful, create virtual file + // Create default version description + let mut version_description = + HashMap::<VirtualFileVersion, VirtualFileVersionDescription>::new(); + version_description.insert( + FIRST_VERSION.to_string(), + VirtualFileVersionDescription { + creator: member_id.clone(), + description: "Track".to_string(), + }, + ); + // Create metadata + let mut meta = VirtualFileMeta { + current_version: FIRST_VERSION.to_string(), + hold_member: member_id.clone(), // The holder of the newly created virtual file is the creator by default + version_description, + histories: Vec::default(), + }; + + // Add first version + meta.histories.push(FIRST_VERSION.to_string()); + + // Write metadata to file + VirtualFileMeta::write_to(&meta, self.virtual_file_meta_path(&new_id)).await?; + + // Move temp file to virtual file directory + if let Some(parent) = move_path.parent() + && !parent.exists() + { + fs::create_dir_all(parent).await?; + } + fs::rename(receive_path, move_path).await?; + + // + + Ok(new_id) + } + Err(e) => { + // Read failed, remove temp file. + if receive_path.exists() { + fs::remove_file(receive_path).await?; + } + + Err(Error::other(e)) + } + } + } + + /// Update a virtual file from a connection instance + /// + /// It's the only way to update virtual files! + /// When the target machine executes `write_file`, use this function instead of `read_file`, + /// and provide the member ID of the transmitting member. + /// + /// The system will automatically receive the file and + /// update the virtual file. + /// + /// Note: The specified member must hold the edit right of the file, + /// otherwise the file reception will not be allowed. + /// + /// Make sure to obtain the edit right of the file before calling this function. + pub async fn update_virtual_file_from_connection( + &self, + instance: &mut ConnectionInstance, + member: &MemberId, + virtual_file_id: &VirtualFileId, + new_version: &VirtualFileVersion, + description: VirtualFileVersionDescription, + ) -> Result<(), std::io::Error> { + let new_version = snake_case!(new_version.clone()); + let mut meta = self.virtual_file_meta(virtual_file_id).await?; + + // Check if the member has edit right + self.check_virtual_file_edit_right(member, virtual_file_id) + .await?; + + // Check if the new version already exists + if meta.version_description.contains_key(&new_version) { + return Err(Error::new( + ErrorKind::AlreadyExists, + format!( + "Version `{}` already exists for virtual file `{}`", + new_version, virtual_file_id + ), + )); + } + + // Verify success + let receive_path = self.virtual_file_temp_path(); + let move_path = self.virtual_file_real_path(virtual_file_id, &new_version); + + match instance.read_file(receive_path.clone()).await { + Ok(_) => { + // Read success, move temp file to real path. + fs::rename(receive_path, move_path).await?; + + // Update metadata + meta.current_version = new_version.clone(); + meta.version_description + .insert(new_version.clone(), description); + meta.histories.push(new_version); + VirtualFileMeta::write_to(&meta, self.virtual_file_meta_path(virtual_file_id)) + .await?; + + Ok(()) + } + Err(e) => { + // Read failed, remove temp file. + if receive_path.exists() { + fs::remove_file(receive_path).await?; + } + + Err(Error::other(e)) + } + } + } + + /// Update virtual file from existing version + /// + /// This operation creates a new version based on the specified old version file instance. + /// The new version will retain the same version name as the old version, but use a different version number. + /// After the update, this version will be considered newer than the original version when comparing versions. + pub async fn update_virtual_file_from_exist_version( + &self, + member: &MemberId, + virtual_file_id: &VirtualFileId, + old_version: &VirtualFileVersion, + ) -> Result<(), std::io::Error> { + let old_version = snake_case!(old_version.clone()); + let mut meta = self.virtual_file_meta(virtual_file_id).await?; + + // Check if the member has edit right + self.check_virtual_file_edit_right(member, virtual_file_id) + .await?; + + // Ensure virtual file exist + let Ok(_) = self.virtual_file(virtual_file_id) else { + return Err(Error::new( + ErrorKind::NotFound, + format!("Virtual file `{}` not found!", virtual_file_id), + )); + }; + + // Ensure version exist + if !meta.version_exists(&old_version) { + return Err(Error::new( + ErrorKind::NotFound, + format!("Version `{}` not found!", old_version), + )); + } + + // Ok, Create new version + meta.current_version = old_version.clone(); + meta.histories.push(old_version); + VirtualFileMeta::write_to(&meta, self.virtual_file_meta_path(virtual_file_id)).await?; + + Ok(()) + } + + /// Grant a member the edit right for a virtual file + /// This operation takes effect immediately upon success + pub async fn grant_virtual_file_edit_right( + &self, + member_id: &MemberId, + virtual_file_id: &VirtualFileId, + ) -> Result<(), std::io::Error> { + let mut meta = self.virtual_file_meta(virtual_file_id).await?; + meta.hold_member = member_id.clone(); + self.write_virtual_file_meta(virtual_file_id, &meta).await + } + + /// Check if a member has the edit right for a virtual file + pub async fn has_virtual_file_edit_right( + &self, + member_id: &MemberId, + virtual_file_id: &VirtualFileId, + ) -> Result<bool, std::io::Error> { + let meta = self.virtual_file_meta(virtual_file_id).await?; + Ok(meta.hold_member.eq(member_id)) + } + + /// Check if a member has the edit right for a virtual file and return Result + /// Returns Ok(()) if the member has edit right, otherwise returns PermissionDenied error + pub async fn check_virtual_file_edit_right( + &self, + member_id: &MemberId, + virtual_file_id: &VirtualFileId, + ) -> Result<(), std::io::Error> { + if !self + .has_virtual_file_edit_right(member_id, virtual_file_id) + .await? + { + return Err(Error::new( + ErrorKind::PermissionDenied, + format!( + "Member `{}` not allowed to update virtual file `{}`", + member_id, virtual_file_id + ), + )); + } + Ok(()) + } + + /// Revoke the edit right for a virtual file from the current holder + /// This operation takes effect immediately upon success + pub async fn revoke_virtual_file_edit_right( + &self, + virtual_file_id: &VirtualFileId, + ) -> Result<(), std::io::Error> { + let mut meta = self.virtual_file_meta(virtual_file_id).await?; + meta.hold_member = String::default(); + self.write_virtual_file_meta(virtual_file_id, &meta).await + } +} + +impl<'a> VirtualFile<'a> { + /// Get id of VirtualFile + pub fn id(&self) -> VirtualFileId { + self.id.clone() + } + + /// Read metadata of VirtualFile + pub async fn read_meta(&self) -> Result<VirtualFileMeta, std::io::Error> { + self.current_vault.virtual_file_meta(&self.id).await + } +} + +impl VirtualFileMeta { + /// Get all versions of the virtual file + pub fn versions(&self) -> &Vec<VirtualFileVersion> { + &self.histories + } + + /// Get the total number of versions for this virtual file + pub fn version_len(&self) -> i32 { + self.histories.len() as i32 + } + + /// Check if a specific version exists + /// Returns true if the version exists, false otherwise + pub fn version_exists(&self, version: &VirtualFileVersion) -> bool { + self.versions().iter().any(|v| v == version) + } + + /// Get the version number (index) for a given version name + /// Returns None if the version doesn't exist + pub fn version_num(&self, version: &VirtualFileVersion) -> Option<i32> { + self.histories + .iter() + .rev() + .position(|v| v == version) + .map(|pos| (self.histories.len() - 1 - pos) as i32) + } + + /// Get the version name for a given version number (index) + /// Returns None if the version number is out of range + pub fn version_name(&self, version_num: i32) -> Option<VirtualFileVersion> { + self.histories.get(version_num as usize).cloned() + } +} |
