diff options
| author | 魏曹先生 <1992414357@qq.com> | 2025-09-25 13:22:50 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2025-09-25 13:22:50 +0800 |
| commit | 07b1ce2c3398c69b021ea2418a057eb7b6cacc40 (patch) | |
| tree | 73d6c8f35df425b15c905819fb0d37aec02e6563 /crates/vcs | |
| parent | 28828776b7aaa6f2bf723837eab1cae859582be5 (diff) | |
Rename `env` to `vcs`
Diffstat (limited to 'crates/vcs')
| -rw-r--r-- | crates/vcs/Cargo.toml | 21 | ||||
| -rw-r--r-- | crates/vcs/src/constants.rs | 50 | ||||
| -rw-r--r-- | crates/vcs/src/current.rs | 78 | ||||
| -rw-r--r-- | crates/vcs/src/lib.rs | 5 | ||||
| -rw-r--r-- | crates/vcs/src/workspace.rs | 4 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/local.rs | 106 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/local/config.rs | 53 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/member.rs | 67 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/user.rs | 1 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/user/accounts.rs | 1 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/vault.rs | 137 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/vault/config.rs | 46 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/vault/member.rs | 105 | ||||
| -rw-r--r-- | crates/vcs/src/workspace/vault/virtual_file.rs | 422 | ||||
| -rw-r--r-- | crates/vcs/vcs_test/Cargo.toml | 12 | ||||
| -rw-r--r-- | crates/vcs/vcs_test/src/lib.rs | 21 | ||||
| -rw-r--r-- | crates/vcs/vcs_test/src/test_vault_setup_and_member_register.rs | 69 | ||||
| -rw-r--r-- | crates/vcs/vcs_test/src/test_virtual_file_creation_and_update.rs | 170 |
18 files changed, 1368 insertions, 0 deletions
diff --git a/crates/vcs/Cargo.toml b/crates/vcs/Cargo.toml new file mode 100644 index 0000000..ec1fb14 --- /dev/null +++ b/crates/vcs/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vcs" +edition = "2024" +version.workspace = true + +[dependencies] +tcp_connection = { path = "../utils/tcp_connection" } +cfg_file = { path = "../utils/cfg_file", features = ["default"] } +string_proc = { path = "../utils/string_proc" } + +# Identity +uuid = { version = "1.18.1", features = ["v4", "serde"] } + +# Serialization +serde = { version = "1.0.219", features = ["derive"] } + +# Async & Networking +tokio = { version = "1.46.1", features = ["full"] } + +# Filesystem +dirs = "6.0.0" diff --git a/crates/vcs/src/constants.rs b/crates/vcs/src/constants.rs new file mode 100644 index 0000000..eb7b019 --- /dev/null +++ b/crates/vcs/src/constants.rs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------------------- +// + +// Project +pub const PATH_TEMP: &str = "./.temp/"; + +// Default Port +pub const PORT: u16 = 25331; + +// Server +// Server - Vault (Main) +pub const SERVER_FILE_VAULT: &str = "./vault.toml"; // crates::env::vault::vault_config + +// Server - Sheets +pub const SERVER_PATH_SHEETS: &str = "./sheets/"; +pub const SERVER_FILE_SHEET: &str = "./sheets/{sheet-name}.yaml"; + +// Server - Members +pub const SERVER_PATH_MEMBERS: &str = "./members/"; +pub const SERVER_PATH_MEMBER_PUB: &str = "./key/"; +pub const SERVER_FILE_MEMBER_INFO: &str = "./members/{member_id}.toml"; // crates::env::member::manager +pub const SERVER_FILE_MEMBER_PUB: &str = "./key/{member_id}.pem"; // crates::utils::tcp_connection::instance + +// Server - Virtual File Storage +pub const SERVER_PATH_VIRTUAL_FILE_TEMP: &str = "./.temp/{temp_name}"; +pub const SERVER_PATH_VIRTUAL_FILE_ROOT: &str = "./storage/"; +pub const SERVER_PATH_VIRTUAL_FILE_STORAGE: &str = "./storage/{vf_id}/"; +pub const SERVER_FILE_VIRTUAL_FILE_VERSION_INSTANCE: &str = "./storage/{vf_id}/{vf_version}.rf"; +pub const SERVER_FILE_VIRTUAL_FILE_META: &str = "./storage/{vf_id}/meta.yaml"; + +pub const SERVER_FILE_README: &str = "./README.md"; + +// ------------------------------------------------------------------------------------- + +// Client +pub const CLIENT_PATH_WORKSPACE_ROOT: &str = "./.jv/"; + +// Client - Workspace (Main) +pub const CLIENT_FILE_WORKSPACE: &str = "./.jv/workspace.toml"; // crates::env::local::local_config + +// Client - Other +pub const CLIENT_FILE_IGNOREFILES: &str = ".jgnore .gitignore"; // Support gitignore file. +pub const CLIENT_FILE_README: &str = "./README.md"; + +// ------------------------------------------------------------------------------------- + +// User - Verify (Documents path) +pub const USER_FILE_ACCOUNTS: &str = "./accounts/"; +pub const USER_FILE_KEY_PUB: &str = "./accounts/{self_id}_private.pem"; +pub const USER_FILE_MEMBER: &str = "./accounts/{self_id}.toml"; diff --git a/crates/vcs/src/current.rs b/crates/vcs/src/current.rs new file mode 100644 index 0000000..97b5058 --- /dev/null +++ b/crates/vcs/src/current.rs @@ -0,0 +1,78 @@ +use crate::constants::*; +use std::io::{self, Error}; +use std::{env::set_current_dir, path::PathBuf}; + +/// Find the nearest vault or local workspace and correct the `current_dir` to it +pub fn correct_current_dir() -> Result<(), io::Error> { + if let Some(local_workspace) = current_local_path() { + set_current_dir(local_workspace)?; + return Ok(()); + } + if let Some(vault) = current_vault_path() { + set_current_dir(vault)?; + return Ok(()); + } + Err(Error::new( + io::ErrorKind::NotFound, + "Could not find any vault or local workspace!", + )) +} + +/// Get the nearest Vault directory from `current_dir` +pub fn current_vault_path() -> Option<PathBuf> { + let current_dir = std::env::current_dir().ok()?; + find_vault_path(current_dir) +} + +/// Get the nearest local workspace from `current_dir` +pub fn current_local_path() -> Option<PathBuf> { + let current_dir = std::env::current_dir().ok()?; + find_local_path(current_dir) +} + +/// Get the nearest Vault directory from the specified path +pub fn find_vault_path(path: impl Into<PathBuf>) -> Option<PathBuf> { + let mut current_path = path.into(); + let vault_file = SERVER_FILE_VAULT; + + loop { + let vault_toml_path = current_path.join(vault_file); + if vault_toml_path.exists() { + return Some(current_path); + } + + if let Some(parent) = current_path.parent() { + current_path = parent.to_path_buf(); + } else { + break; + } + } + + None +} + +/// Get the nearest local workspace from the specified path +pub fn find_local_path(path: impl Into<PathBuf>) -> Option<PathBuf> { + let mut current_path = path.into(); + let workspace_dir = CLIENT_PATH_WORKSPACE_ROOT; + + loop { + let jvc_path = current_path.join(workspace_dir); + if jvc_path.exists() { + return Some(current_path); + } + + if let Some(parent) = current_path.parent() { + current_path = parent.to_path_buf(); + } else { + break; + } + } + + None +} + +/// Get the system's document directory and join with .just_enough_vcs +pub fn current_doc_dir() -> Option<PathBuf> { + dirs::document_dir().map(|path| path.join(".just_enough_vcs")) +} diff --git a/crates/vcs/src/lib.rs b/crates/vcs/src/lib.rs new file mode 100644 index 0000000..15a315f --- /dev/null +++ b/crates/vcs/src/lib.rs @@ -0,0 +1,5 @@ +pub mod constants; +pub mod current; + +#[allow(dead_code)] +pub mod workspace; diff --git a/crates/vcs/src/workspace.rs b/crates/vcs/src/workspace.rs new file mode 100644 index 0000000..63411a6 --- /dev/null +++ b/crates/vcs/src/workspace.rs @@ -0,0 +1,4 @@ +pub mod local; +pub mod member; +pub mod user; +pub mod vault; diff --git a/crates/vcs/src/workspace/local.rs b/crates/vcs/src/workspace/local.rs new file mode 100644 index 0000000..0119952 --- /dev/null +++ b/crates/vcs/src/workspace/local.rs @@ -0,0 +1,106 @@ +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}, + workspace::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 Some(local_path) = find_local_path(local_path) else { + return None; + }; + Some(Self { config, local_path }) + } + + /// Initialize local workspace in the current directory. + pub fn init_current_dir(config: LocalConfig) -> Option<Self> { + let Some(local_path) = current_local_path() else { + return None; + }; + 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 = format!( + "\ +# 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!* +" + ) + .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/src/workspace/local/config.rs b/crates/vcs/src/workspace/local/config.rs new file mode 100644 index 0000000..d641880 --- /dev/null +++ b/crates/vcs/src/workspace/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::workspace::vault::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/src/workspace/member.rs b/crates/vcs/src/workspace/member.rs new file mode 100644 index 0000000..208c78c --- /dev/null +++ b/crates/vcs/src/workspace/member.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; + +use cfg_file::ConfigFile; +use serde::{Deserialize, Serialize}; +use string_proc::snake_case; + +#[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/src/workspace/user.rs b/crates/vcs/src/workspace/user.rs new file mode 100644 index 0000000..9bb4894 --- /dev/null +++ b/crates/vcs/src/workspace/user.rs @@ -0,0 +1 @@ +pub mod accounts; diff --git a/crates/vcs/src/workspace/user/accounts.rs b/crates/vcs/src/workspace/user/accounts.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/vcs/src/workspace/user/accounts.rs @@ -0,0 +1 @@ + diff --git a/crates/vcs/src/workspace/vault.rs b/crates/vcs/src/workspace/vault.rs new file mode 100644 index 0000000..caac662 --- /dev/null +++ b/crates/vcs/src/workspace/vault.rs @@ -0,0 +1,137 @@ +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_VIRTUAL_FILE_ROOT, + }, + current::{current_vault_path, find_vault_path}, + workspace::vault::config::VaultConfig, +}; + +pub mod config; +pub mod member; +pub mod virtual_file; + +pub type MemberId = String; + +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 Some(vault_path) = find_vault_path(vault_path) else { + return None; + }; + Some(Self { config, vault_path }) + } + + /// Initialize vault + pub fn init_current_dir(config: VaultConfig) -> Option<Self> { + let Some(vault_path) = current_vault_path() else { + return None; + }; + 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_VIRTUAL_FILE_ROOT))?; + + // 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_VIRTUAL_FILE_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/src/workspace/vault/config.rs b/crates/vcs/src/workspace/vault/config.rs new file mode 100644 index 0000000..5414e4d --- /dev/null +++ b/crates/vcs/src/workspace/vault/config.rs @@ -0,0 +1,46 @@ +use cfg_file::ConfigFile; +use serde::{Deserialize, Serialize}; + +use crate::constants::SERVER_FILE_VAULT; +use crate::workspace::member::Member; +use crate::workspace::vault::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>, +} + +impl Default for VaultConfig { + fn default() -> Self { + Self { + vault_name: "JustEnoughVault".to_string(), + vault_admin_list: Vec::new(), + } + } +} + +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/src/workspace/vault/member.rs b/crates/vcs/src/workspace/vault/member.rs new file mode 100644 index 0000000..793ba2a --- /dev/null +++ b/crates/vcs/src/workspace/vault/member.rs @@ -0,0 +1,105 @@ +use std::{ + fs, + io::{Error, ErrorKind}, + path::PathBuf, +}; + +use cfg_file::config::ConfigFile; + +use crate::{ + constants::{SERVER_FILE_MEMBER_INFO, SERVER_FILE_MEMBER_PUB}, + workspace::{ + member::Member, + vault::{MemberId, 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!")) + } + + /// Update member info + pub async fn update_member(&self, member: Member) -> Result<(), std::io::Error> { + // Ensure member exist + if let Some(_) = self.member_cfg(&member.id()) { + 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 let Some(_) = self.member_cfg(&member.id()) { + 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 { + let path = self + .vault_path + .join(SERVER_FILE_MEMBER_INFO.replace(ID_PARAM, id.to_string().as_str())); + path + } + + /// 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 { + let path = self + .vault_path + .join(SERVER_FILE_MEMBER_PUB.replace(ID_PARAM, id.to_string().as_str())); + path + } +} diff --git a/crates/vcs/src/workspace/vault/virtual_file.rs b/crates/vcs/src/workspace/vault/virtual_file.rs new file mode 100644 index 0000000..321f0e1 --- /dev/null +++ b/crates/vcs/src/workspace/vault/virtual_file.rs @@ -0,0 +1,422 @@ +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_VIRTUAL_FILE_META, SERVER_FILE_VIRTUAL_FILE_VERSION_INSTANCE, + SERVER_PATH_VIRTUAL_FILE_ROOT, SERVER_PATH_VIRTUAL_FILE_STORAGE, + SERVER_PATH_VIRTUAL_FILE_TEMP, + }, + workspace::vault::{MemberId, Vault}, +}; + +pub type VirtualFileId = String; +pub type VirtualFileVersion = String; + +const ID_PARAM: &str = "{vf_id}"; +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, + } + } +} + +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_VIRTUAL_FILE_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_VIRTUAL_FILE_ROOT) + } + + /// Get the directory where a specific virtual file is stored + pub fn virtual_file_dir(&self, id: VirtualFileId) -> PathBuf { + self.vault_path() + .join(SERVER_PATH_VIRTUAL_FILE_STORAGE.replace(ID_PARAM, &id.to_string())) + } + + /// 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_VIRTUAL_FILE_VERSION_INSTANCE + .replace(ID_PARAM, &id.to_string()) + .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_VIRTUAL_FILE_META.replace(ID_PARAM, &id.to_string())) + } + + /// Get the virtual file with the given ID + pub fn virtual_file(&self, id: &VirtualFileId) -> Option<VirtualFile<'_>> { + let dir = self.virtual_file_dir(id.clone()); + if dir.exists() { + Some(VirtualFile { + id: id.clone(), + current_vault: self, + }) + } else { + None + } + } + + /// 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_{}", 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: String::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() { + if !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::new(ErrorKind::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?; + + return Ok(()); + } + Err(e) => { + // Read failed, remove temp file. + if receive_path.exists() { + fs::remove_file(receive_path).await?; + } + + return Err(Error::new(ErrorKind::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 Some(_) = 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() + } +} diff --git a/crates/vcs/vcs_test/Cargo.toml b/crates/vcs/vcs_test/Cargo.toml new file mode 100644 index 0000000..0ed51d8 --- /dev/null +++ b/crates/vcs/vcs_test/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "vcs_test" +edition = "2024" +version.workspace = true + +[dependencies] +tcp_connection = { path = "../../utils/tcp_connection" } +cfg_file = { path = "../../utils/cfg_file", features = ["default"] } +vcs = { path = "../../vcs" } + +# Async & Networking +tokio = { version = "1.46.1", features = ["full"] } diff --git a/crates/vcs/vcs_test/src/lib.rs b/crates/vcs/vcs_test/src/lib.rs new file mode 100644 index 0000000..357ea3f --- /dev/null +++ b/crates/vcs/vcs_test/src/lib.rs @@ -0,0 +1,21 @@ +use std::{env::current_dir, path::PathBuf}; + +use tokio::fs; + +#[cfg(test)] +pub mod test_vault_setup_and_member_register; + +#[cfg(test)] +pub mod test_virtual_file_creation_and_update; + +pub async fn get_test_dir(area: &str) -> Result<PathBuf, std::io::Error> { + let dir = current_dir()?.join(".temp").join("test").join(area); + if !dir.exists() { + std::fs::create_dir_all(&dir)?; + } else { + // Regenerate existing directory + fs::remove_dir_all(&dir).await?; + fs::create_dir_all(&dir).await?; + } + Ok(dir) +} diff --git a/crates/vcs/vcs_test/src/test_vault_setup_and_member_register.rs b/crates/vcs/vcs_test/src/test_vault_setup_and_member_register.rs new file mode 100644 index 0000000..e84a411 --- /dev/null +++ b/crates/vcs/vcs_test/src/test_vault_setup_and_member_register.rs @@ -0,0 +1,69 @@ +use std::io::Error; + +use cfg_file::config::ConfigFile; +use env::{ + constants::{ + SERVER_FILE_MEMBER_INFO, SERVER_FILE_README, SERVER_FILE_VAULT, SERVER_PATH_MEMBER_PUB, + SERVER_PATH_MEMBERS, SERVER_PATH_SHEETS, SERVER_PATH_VIRTUAL_FILE_ROOT, + }, + workspace::{ + member::Member, + vault::{Vault, config::VaultConfig}, + }, +}; + +use crate::get_test_dir; + +#[tokio::test] +async fn test_vault_setup_and_member_register() -> Result<(), std::io::Error> { + let dir = get_test_dir("member_register").await?; + + // Setup vault + Vault::setup_vault(dir.clone()).await?; + + // Check if the following files and directories are created in `dir`: + // Files: SERVER_FILE_VAULT, SERVER_FILE_README + // Directories: SERVER_PATH_SHEETS, + // SERVER_PATH_MEMBERS, + // SERVER_PATH_MEMBER_PUB, + // SERVER_PATH_VIRTUAL_FILE_ROOT + assert!(dir.join(SERVER_FILE_VAULT).exists()); + assert!(dir.join(SERVER_FILE_README).exists()); + assert!(dir.join(SERVER_PATH_SHEETS).exists()); + assert!(dir.join(SERVER_PATH_MEMBERS).exists()); + assert!(dir.join(SERVER_PATH_MEMBER_PUB).exists()); + assert!(dir.join(SERVER_PATH_VIRTUAL_FILE_ROOT).exists()); + + // Get vault + let config = VaultConfig::read_from(dir.join(SERVER_FILE_VAULT)).await?; + let Some(vault) = Vault::init(config, &dir) else { + return Err(Error::new(std::io::ErrorKind::NotFound, "Vault not found!")); + }; + + // Add member + let member_id = "test_member"; + vault + .register_member_to_vault(Member::new(member_id)) + .await?; + + const ID_PARAM: &str = "{member_id}"; + + // Check if the member info file exists + assert_eq!( + dir.join(SERVER_FILE_MEMBER_INFO.replace(ID_PARAM, member_id)) + .exists(), + true + ); + + // Remove member + vault.remove_member_from_vault(&member_id.to_string())?; + + // Check if the member info file not exists + assert_eq!( + dir.join(SERVER_FILE_MEMBER_INFO.replace(ID_PARAM, member_id)) + .exists(), + false + ); + + Ok(()) +} diff --git a/crates/vcs/vcs_test/src/test_virtual_file_creation_and_update.rs b/crates/vcs/vcs_test/src/test_virtual_file_creation_and_update.rs new file mode 100644 index 0000000..7da2bef --- /dev/null +++ b/crates/vcs/vcs_test/src/test_virtual_file_creation_and_update.rs @@ -0,0 +1,170 @@ +use std::time::Duration; + +use cfg_file::config::ConfigFile; +use env::{ + constants::SERVER_FILE_VAULT, + workspace::{ + member::Member, + vault::{Vault, config::VaultConfig, virtual_file::VirtualFileVersionDescription}, + }, +}; +use tcp_connection::{ + handle::{ClientHandle, ServerHandle}, + target::TcpServerTarget, + target_configure::ServerTargetConfig, +}; +use tokio::{ + join, + time::{sleep, timeout}, +}; + +use crate::get_test_dir; + +struct VirtualFileCreateClientHandle; +struct VirtualFileCreateServerHandle; + +impl ClientHandle<VirtualFileCreateServerHandle> for VirtualFileCreateClientHandle { + fn process( + mut instance: tcp_connection::instance::ConnectionInstance, + ) -> impl Future<Output = ()> + Send { + async move { + let dir = get_test_dir("virtual_file_creation_and_update_2") + .await + .unwrap(); + // Create first test file for virtual file creation + let test_content_1 = b"Test file content for virtual file creation"; + let temp_file_path_1 = dir.join("test_virtual_file_1.txt"); + + tokio::fs::write(&temp_file_path_1, test_content_1) + .await + .unwrap(); + + // Send the first file to server for virtual file creation + instance.write_file(&temp_file_path_1).await.unwrap(); + + // Create second test file for virtual file update + let test_content_2 = b"Updated test file content for virtual file"; + let temp_file_path_2 = dir.join("test_virtual_file_2.txt"); + + tokio::fs::write(&temp_file_path_2, test_content_2) + .await + .unwrap(); + + // Send the second file to server for virtual file update + instance.write_file(&temp_file_path_2).await.unwrap(); + } + } +} + +impl ServerHandle<VirtualFileCreateClientHandle> for VirtualFileCreateServerHandle { + fn process( + mut instance: tcp_connection::instance::ConnectionInstance, + ) -> impl Future<Output = ()> + Send { + async move { + let dir = get_test_dir("virtual_file_creation_and_update") + .await + .unwrap(); + + // Setup vault + Vault::setup_vault(dir.clone()).await.unwrap(); + + // Read vault + let Some(vault) = Vault::init( + VaultConfig::read_from(dir.join(SERVER_FILE_VAULT)) + .await + .unwrap(), + &dir, + ) else { + panic!("No vault found!"); + }; + + // Register member + let member_id = "test_member"; + vault + .register_member_to_vault(Member::new(member_id)) + .await + .unwrap(); + + // Create visual file + let virtual_file_id = vault + .create_virtual_file_from_connection(&mut instance, &member_id.to_string()) + .await + .unwrap(); + + // Grant edit right to member + vault + .grant_virtual_file_edit_right(&member_id.to_string(), &virtual_file_id) + .await + .unwrap(); + + // Update visual file + vault + .update_virtual_file_from_connection( + &mut instance, + &member_id.to_string(), + &virtual_file_id, + &"2".to_string(), + VirtualFileVersionDescription { + creator: member_id.to_string(), + description: "Update".to_string(), + }, + ) + .await + .unwrap(); + } + } +} + +#[tokio::test] +async fn test_virtual_file_creation_and_update() -> Result<(), std::io::Error> { + let host = "localhost:5009"; + + // Server setup + let Ok(server_target) = TcpServerTarget::< + VirtualFileCreateClientHandle, + VirtualFileCreateServerHandle, + >::from_domain(host) + .await + else { + panic!("Test target built failed from a domain named `{}`", host); + }; + + // Client setup + let Ok(client_target) = TcpServerTarget::< + VirtualFileCreateClientHandle, + VirtualFileCreateServerHandle, + >::from_domain(host) + .await + else { + panic!("Test target built failed from a domain named `{}`", host); + }; + + let future_server = async move { + // Only process once + let configured_server = server_target.server_cfg(ServerTargetConfig::default().once()); + + // Listen here + let _ = configured_server.listen().await; + }; + + let future_client = async move { + // Wait for server start + let _ = sleep(Duration::from_secs_f32(1.5)).await; + + // Connect here + let _ = client_target.connect().await; + }; + + let test_timeout = Duration::from_secs(15); + + timeout(test_timeout, async { join!(future_client, future_server) }) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Test timed out after {:?}", test_timeout), + ) + })?; + + Ok(()) +} |
