From 1e9c97c21f8a4e55420712b054895ff8b4f9a849 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Fri, 19 Jun 2026 01:40:38 +0800 Subject: feat(rola-bucket): add bucket bind management Implement bucket bind CRUD operations and config loading, along with CLI integration for listing, setting, and removing bucket bindings. --- rola-bucket/Cargo.toml | 1 + rola-bucket/res/bucket.toml | 3 + rola-bucket/src/bucket.rs | 6 +- rola-bucket/src/bucket/bind.rs | 210 ++++++++++++++++++++++++++++++++ rola-bucket/src/bucket/bind/test.rs | 231 ++++++++++++++++++++++++++++++++++++ rola-bucket/src/bucket/config.rs | 30 +++++ rola-bucket/src/bucket/idmap.rs | 0 rola-bucket/src/bucket/init.rs | 27 ++--- rola-bucket/src/bucket/space.rs | 4 +- 9 files changed, 494 insertions(+), 18 deletions(-) create mode 100644 rola-bucket/src/bucket/bind.rs create mode 100644 rola-bucket/src/bucket/bind/test.rs create mode 100644 rola-bucket/src/bucket/config.rs create mode 100644 rola-bucket/src/bucket/idmap.rs (limited to 'rola-bucket') diff --git a/rola-bucket/Cargo.toml b/rola-bucket/Cargo.toml index 61d7940..8899591 100644 --- a/rola-bucket/Cargo.toml +++ b/rola-bucket/Cargo.toml @@ -14,4 +14,5 @@ space-system.workspace = true thiserror.workspace = true tokio.workspace = true +serde.workspace = true log.workspace = true diff --git a/rola-bucket/res/bucket.toml b/rola-bucket/res/bucket.toml index 01abd07..adc7770 100644 --- a/rola-bucket/res/bucket.toml +++ b/rola-bucket/res/bucket.toml @@ -1 +1,4 @@ [bucket] +# Bucket 类型 + type = "client" # 本地存储,使用本地 ID +# type = "bucket" # 中心存储,使用全局 ID diff --git a/rola-bucket/src/bucket.rs b/rola-bucket/src/bucket.rs index b70afd8..fe892f0 100644 --- a/rola-bucket/src/bucket.rs +++ b/rola-bucket/src/bucket.rs @@ -4,9 +4,11 @@ use crate::AsyncBucketTransferProtocol; use crate::LocalFileSystemProtocol; use space_system::SpaceRootTest; -mod init; -// pub use init::*; +pub mod bind; +pub mod config; +pub mod init; +mod idmap; mod space; /// Represents the state of a bucket in the transfer protocol. diff --git a/rola-bucket/src/bucket/bind.rs b/rola-bucket/src/bucket/bind.rs new file mode 100644 index 0000000..87f3382 --- /dev/null +++ b/rola-bucket/src/bucket/bind.rs @@ -0,0 +1,210 @@ +use serde::{Deserialize, Serialize}; +use shared_constants::bucket::PREFIX_BUCKET_BIND; +use space_system::{Space, SpaceError}; +use std::borrow::Borrow; +use std::cmp::Ordering; +use std::fmt; +use std::fs::ReadDir; +use std::io::ErrorKind::NotFound; +use std::ops::{Deref, DerefMut}; + +use crate::{AsyncBucketTransferProtocol, Bucket}; + +#[cfg(test)] +mod test; + +/// Represents a binding between a bucket and a URL. +/// +/// `BucketBind` is a newtype wrapper around a `String` that stores a URL +/// associated with a bucket. It provides convenient access to the underlying +/// URL string through `Deref`, `DerefMut`, `Borrow`, and `Display` trait +/// implementations. +#[derive(Debug, Serialize, Deserialize, Default, PartialEq, Eq, Clone)] +pub struct BucketBind { + /// The index of the bucket bind + index: u8, + + /// The URL associated with the bucket bind. + url: String, +} + +impl BucketBind { + /// Creates a new `BucketBind` with the given URL. + fn new(index: u8, url: impl Into) -> Self { + Self { + index, + url: url.into(), + } + } + + /// Returns the index of the bucket bind. + pub fn get_index(&self) -> u8 { + self.index + } + + /// Returns a reference to the URL of the bucket bind. + pub fn get_url(&self) -> &str { + &self.url + } +} + +/// Reads all bucket bind records from the space. +/// +/// This function traverses the root directory of the specified space, filters files +/// that start with a specific prefix (`PREFIX_BUCKET_BIND`), parses the index and URL +/// from each binding record, and returns them as `BucketBind` objects. +pub fn read_bucket_binds( + space: &Space>, +) -> Result, SpaceError> { + // Fixed prefix for bucket bind filenames + const PREFIX: &str = PREFIX_BUCKET_BIND; + + // Open a read stream for the space root directory + let reader: ReadDir = space.read_dir(".")?; + let mut binds = Vec::new(); + + // Loop through each entry in the directory + for entry in reader { + let entry = entry?; + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + + // Only process files starting with the bind prefix + if let Some(suffix) = name.strip_prefix(PREFIX) { + // Extract the part after the prefix as the index string + // Attempt to parse the suffix as a u8 index value + if let Ok(index) = suffix.parse::() { + // Read the file content as the URL + let content = space.read_to_string(&name)?; + let url = content.trim().to_string(); + + // Add the parsed binding record to the list + binds.push(BucketBind::new(index, url)); + } + } + } + + // Sort by index before returning + binds.sort(); + Ok(binds) +} + +/// Writes a bucket bind record to the space. +/// +/// This function creates or updates a binding between a bucket and a URL +/// at the specified index. It writes the URL content to a file named +/// with the prefix `PREFIX_BUCKET_BIND` followed by the zero-padded index. +pub fn write_bucket_bind( + space: &Space>, + idx: u8, + url: &str, +) -> Result<(), SpaceError> { + const PREFIX: &str = PREFIX_BUCKET_BIND; + let file_name = format!("{}{:03}", PREFIX, idx); + space.write(&file_name, url.trim()) +} + +/// Reads a single bucket bind record from the space by index. +/// +/// This function looks for a file named with the prefix `PREFIX_BUCKET_BIND` +/// followed by the zero-padded index, reads its content as a URL, and returns +/// the corresponding `BucketBind`. Returns `None` if the file does not exist. +pub fn read_bucket_bind( + space: &Space>, + idx: u8, +) -> Result, SpaceError> { + const PREFIX: &str = PREFIX_BUCKET_BIND; + let file_name = format!("{}{:03}", PREFIX, idx); + + match space.read_to_string(&file_name) { + Ok(content) => { + let url = content.trim().to_string(); + Ok(Some(BucketBind::new(idx, url))) + } + Err(SpaceError::Io(err)) => { + if err.kind() == NotFound { + Ok(None) + } else { + Err(SpaceError::Io(err)) + } + } + Err(e) => Err(e), + } +} + +/// Checks whether a bucket bind record exists at the given index. +/// +/// Returns `true` if a file named with the prefix `PREFIX_BUCKET_BIND` followed +/// by the zero-padded index exists in the space, `false` otherwise. +pub fn check_bucket_bind_exists( + space: &Space>, + idx: u8, +) -> Result { + const PREFIX: &str = PREFIX_BUCKET_BIND; + let file_name = format!("{}{:03}", PREFIX, idx); + + match space.read_to_string(&file_name) { + Ok(_) => Ok(true), + Err(SpaceError::Io(err)) => { + if err.kind() == NotFound { + Ok(false) + } else { + Err(SpaceError::Io(err)) + } + } + Err(e) => Err(e), + } +} + +/// Removes a bucket bind record from the space by index. +/// +/// This function deletes the file named with the prefix `PREFIX_BUCKET_BIND` +/// followed by the zero-padded index from the space. Returns `Ok(())` if the +/// deletion succeeds, or an error if the operation fails (including if the +/// file does not exist). +pub fn remove_bucket_bind( + space: &Space>, + idx: u8, +) -> Result<(), SpaceError> { + const PREFIX: &str = PREFIX_BUCKET_BIND; + let file_name = format!("{}{:03}", PREFIX, idx); + space.remove_file(&file_name) +} + +impl Ord for BucketBind { + fn cmp(&self, other: &Self) -> Ordering { + self.index.cmp(&other.index) + } +} + +impl PartialOrd for BucketBind { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Deref for BucketBind { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.url + } +} + +impl DerefMut for BucketBind { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.url + } +} + +impl Borrow for BucketBind { + fn borrow(&self) -> &String { + &self.url + } +} + +impl fmt::Display for BucketBind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.url) + } +} diff --git a/rola-bucket/src/bucket/bind/test.rs b/rola-bucket/src/bucket/bind/test.rs new file mode 100644 index 0000000..10126b1 --- /dev/null +++ b/rola-bucket/src/bucket/bind/test.rs @@ -0,0 +1,231 @@ +use std::{fs, path::Path}; + +use shared_constants::bucket::FILE_BUCKET_BIND; +use shared_functions::rola_test_sandbox; +use space_system::Space; + +use crate::{ + Bucket, NoProtocol, + bind::{ + BucketBind, check_bucket_bind_exists, read_bucket_bind, read_bucket_binds, + remove_bucket_bind, write_bucket_bind, + }, +}; + +fn init_bucket(path: &Path) -> Space> { + let bucket = Bucket::::new_local(); + let mut space = Space::new(bucket); + space.set_current_dir(&path).unwrap(); + space.init(path).unwrap(); + space +} + +#[test] +fn test_read_bucket_binds() { + let sandbox = rola_test_sandbox("bucket_bind_read"); + + let b = init_bucket(&sandbox.path); + + let bind_1 = sandbox.join(FILE_BUCKET_BIND("1")); + let bind_2 = sandbox.join(FILE_BUCKET_BIND("2")); + let bind_3 = sandbox.join(FILE_BUCKET_BIND("3")); + let bind_fail = sandbox.join(FILE_BUCKET_BIND("@")); + let other = sandbox.join("ot"); + + fs::write(bind_1, "./bucket1").unwrap(); + fs::write(bind_2, "\n./bucket2").unwrap(); + fs::write(bind_3, "./bucket3\nbbb").unwrap(); + fs::write(bind_fail, "omg").unwrap(); + fs::write(other, "ok").unwrap(); + + let result = read_bucket_binds(&b).unwrap(); + assert!(result.contains(&BucketBind::new(1, "./bucket1"))); + assert!(result.contains(&BucketBind::new(2, "./bucket2"))); + assert!(result.contains(&BucketBind::new(3, "./bucket3\nbbb"))); + + assert_eq!(result.len(), 3); +} + +#[test] +fn test_write_and_read_bucket_bind() { + let sandbox = rola_test_sandbox("bucket_bind_write_read"); + + let space = init_bucket(&sandbox.path); + + // Write bucket bind records + write_bucket_bind(&space, 1, "./bucket1").unwrap(); + write_bucket_bind(&space, 2, "./bucket2").unwrap(); + write_bucket_bind(&space, 3, "./bucket3\nbbb").unwrap(); + + // Verify reads return the correct values + let bind1 = read_bucket_bind(&space, 1).unwrap().unwrap(); + assert_eq!(bind1, BucketBind::new(1, "./bucket1")); + + let bind2 = read_bucket_bind(&space, 2).unwrap().unwrap(); + assert_eq!(bind2, BucketBind::new(2, "./bucket2")); + + let bind3 = read_bucket_bind(&space, 3).unwrap().unwrap(); + assert_eq!(bind3, BucketBind::new(3, "./bucket3\nbbb")); + + // Read a non-existent bind should return None + let bind4 = read_bucket_bind(&space, 4).unwrap(); + assert!(bind4.is_none()); +} + +#[test] +fn test_write_bucket_bind_trims_whitespace() { + let sandbox = rola_test_sandbox("bucket_bind_trim"); + + let space = init_bucket(&sandbox.path); + + // Write URL with surrounding whitespace + write_bucket_bind(&space, 1, " ./bucket1 ").unwrap(); + + // Verify it was trimmed on write + let bind = read_bucket_bind(&space, 1).unwrap().unwrap(); + assert_eq!(bind, BucketBind::new(1, "./bucket1")); +} + +#[test] +fn test_write_bucket_bind_overwrites_existing() { + let sandbox = rola_test_sandbox("bucket_bind_overwrite"); + + let space = init_bucket(&sandbox.path); + + write_bucket_bind(&space, 1, "./bucket_v1").unwrap(); + write_bucket_bind(&space, 1, "./bucket_v2").unwrap(); + + let bind = read_bucket_bind(&space, 1).unwrap().unwrap(); + assert_eq!(bind, BucketBind::new(1, "./bucket_v2")); +} + +#[test] +fn test_check_bucket_bind_exists() { + let sandbox = rola_test_sandbox("bucket_bind_check_exists"); + + let space = init_bucket(&sandbox.path); + + // Initially, no bucket bind should exist + let exists = check_bucket_bind_exists(&space, 1).unwrap(); + assert!(!exists); + + // Write a bucket bind and verify it exists + write_bucket_bind(&space, 1, "./bucket1").unwrap(); + let exists = check_bucket_bind_exists(&space, 1).unwrap(); + assert!(exists); + + // A different index should still not exist + let exists = check_bucket_bind_exists(&space, 2).unwrap(); + assert!(!exists); + + // Write another bind and check + write_bucket_bind(&space, 2, "./bucket2").unwrap(); + let exists = check_bucket_bind_exists(&space, 2).unwrap(); + assert!(exists); +} + +#[test] +fn test_check_bucket_bind_exists_after_delete() { + let sandbox = rola_test_sandbox("bucket_bind_check_exists_after_delete"); + + let space = init_bucket(&sandbox.path); + + write_bucket_bind(&space, 1, "./bucket1").unwrap(); + assert!(check_bucket_bind_exists(&space, 1).unwrap()); + + // Delete by removing the file directly + let bind_path = sandbox.path.join(format!( + "{}{:03}", + shared_constants::bucket::PREFIX_BUCKET_BIND, + 1 + )); + std::fs::remove_file(bind_path).unwrap(); + + let exists = check_bucket_bind_exists(&space, 1).unwrap(); + assert!(!exists); +} + +#[test] +fn test_remove_bucket_bind() { + let sandbox = rola_test_sandbox("bucket_bind_remove"); + + let space = init_bucket(&sandbox.path); + + // Write a bucket bind + write_bucket_bind(&space, 1, "./bucket1").unwrap(); + assert!(check_bucket_bind_exists(&space, 1).unwrap()); + + // Remove it + remove_bucket_bind(&space, 1).unwrap(); + assert!(!check_bucket_bind_exists(&space, 1).unwrap()); +} + +#[test] +fn test_remove_bucket_bind_nonexistent() { + let sandbox = rola_test_sandbox("bucket_bind_remove_nonexistent"); + + let space = init_bucket(&sandbox.path); + + // Removing a non-existent bind should return an error + let result = remove_bucket_bind(&space, 99); + assert!(result.is_err()); +} + +#[test] +fn test_remove_bucket_bind_does_not_affect_others() { + let sandbox = rola_test_sandbox("bucket_bind_remove_others"); + + let space = init_bucket(&sandbox.path); + + // Write multiple bucket binds + write_bucket_bind(&space, 1, "./bucket1").unwrap(); + write_bucket_bind(&space, 2, "./bucket2").unwrap(); + write_bucket_bind(&space, 3, "./bucket3").unwrap(); + + // Remove bind 2 + remove_bucket_bind(&space, 2).unwrap(); + + // Bind 1 and 3 should still exist + assert!(check_bucket_bind_exists(&space, 1).unwrap()); + assert!(!check_bucket_bind_exists(&space, 2).unwrap()); + assert!(check_bucket_bind_exists(&space, 3).unwrap()); + + // Values should be preserved for remaining binds + assert_eq!( + read_bucket_bind(&space, 1).unwrap().unwrap(), + BucketBind::new(1, "./bucket1") + ); + assert_eq!( + read_bucket_bind(&space, 3).unwrap().unwrap(), + BucketBind::new(3, "./bucket3") + ); +} + +#[test] +fn test_remove_bucket_bind_then_read_returns_none() { + let sandbox = rola_test_sandbox("bucket_bind_remove_then_read"); + + let space = init_bucket(&sandbox.path); + + write_bucket_bind(&space, 1, "./bucket1").unwrap(); + remove_bucket_bind(&space, 1).unwrap(); + + let bind = read_bucket_bind(&space, 1).unwrap(); + assert!(bind.is_none()); +} + +#[test] +fn test_remove_bucket_bind_then_write_again() { + let sandbox = rola_test_sandbox("bucket_bind_remove_then_write"); + + let space = init_bucket(&sandbox.path); + + write_bucket_bind(&space, 1, "./bucket_v1").unwrap(); + remove_bucket_bind(&space, 1).unwrap(); + + // Write the same index again + write_bucket_bind(&space, 1, "./bucket_v2").unwrap(); + + let bind = read_bucket_bind(&space, 1).unwrap().unwrap(); + assert_eq!(bind, BucketBind::new(1, "./bucket_v2")); +} diff --git a/rola-bucket/src/bucket/config.rs b/rola-bucket/src/bucket/config.rs new file mode 100644 index 0000000..559db15 --- /dev/null +++ b/rola-bucket/src/bucket/config.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; + +/// Configuration for a bucket. +/// +/// This struct defines how a bucket should be configured, including its type. +#[derive(Default, Deserialize)] +pub struct BucketConfig { + /// The type of the bucket, e.g., client bucket or normal bucket. + /// + /// When deserializing from TOML, this is expected to be under the key `"type"`. + #[serde(rename = "type")] + pub bucket_type: BucketType, +} + +/// Enum for bucket types, used to distinguish different types of buckets. +/// +/// When deserializing, field names are mapped to string values in TOML via `serde(rename)`. +#[derive(Default, Deserialize)] +pub enum BucketType { + /// Client bucket + /// Uses local ID, mapped to remote ID via IDMAP + #[serde(rename = "client")] + ClientBucket, + + /// Normal bucket + /// Uses global ID + #[default] + #[serde(rename = "bucket")] + Bucket, +} diff --git a/rola-bucket/src/bucket/idmap.rs b/rola-bucket/src/bucket/idmap.rs new file mode 100644 index 0000000..e69de29 diff --git a/rola-bucket/src/bucket/init.rs b/rola-bucket/src/bucket/init.rs index 30bf0f4..6834009 100644 --- a/rola-bucket/src/bucket/init.rs +++ b/rola-bucket/src/bucket/init.rs @@ -1,16 +1,18 @@ -use std::path::{Path, PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use shared_constants::{ bucket::{ DIR_BUCKET_COMPRESSED_OBJ, DIR_BUCKET_DELTA, DIR_BUCKET_ID_REVS, DIR_BUCKET_ID_TAGS, - DIR_BUCKET_OBJ, + DIR_BUCKET_IDMAP, DIR_BUCKET_OBJ, }, common::FILE_BUCKET_ROOT_CONFIG, }; use space_system::SpaceError; -use tokio::fs; -pub(crate) async fn init_bucket_at(path: PathBuf) -> Result<(), SpaceError> { +pub(crate) fn init_bucket_at(path: PathBuf) -> Result<(), SpaceError> { let bucket_config_file = path.join(FILE_BUCKET_ROOT_CONFIG); // Check if directory is empty @@ -19,32 +21,29 @@ pub(crate) async fn init_bucket_at(path: PathBuf) -> Result<(), SpaceError> { return Err(SpaceError::RequireEmptyDirectory); } - write_config(&bucket_config_file).await?; - create_dirs(&path).await?; + write_config(&bucket_config_file)?; + create_dirs(&path)?; Ok(()) } -async fn write_config(bucket_config_file: &Path) -> Result<(), SpaceError> { - fs::write(bucket_config_file, include_str!("../../res/bucket.toml")) - .await - .map_err(SpaceError::Io) +fn write_config(bucket_config_file: &Path) -> Result<(), SpaceError> { + fs::write(bucket_config_file, include_str!("../../res/bucket.toml")).map_err(SpaceError::Io) } -async fn create_dirs(bucket_dir: &Path) -> Result<(), SpaceError> { +fn create_dirs(bucket_dir: &Path) -> Result<(), SpaceError> { let dirs = [ DIR_BUCKET_OBJ, DIR_BUCKET_COMPRESSED_OBJ, DIR_BUCKET_DELTA, DIR_BUCKET_ID_REVS, DIR_BUCKET_ID_TAGS, + DIR_BUCKET_IDMAP, ]; for dir in dirs { let full_path = bucket_dir.join(dir); - fs::create_dir_all(&full_path) - .await - .map_err(SpaceError::Io)?; + fs::create_dir_all(&full_path).map_err(SpaceError::Io)?; } Ok(()) diff --git a/rola-bucket/src/bucket/space.rs b/rola-bucket/src/bucket/space.rs index ed1311c..353075d 100644 --- a/rola-bucket/src/bucket/space.rs +++ b/rola-bucket/src/bucket/space.rs @@ -10,10 +10,10 @@ impl SpaceRoot for Bucket

Result<(), space_system::SpaceError> { + fn create_space(path: &std::path::Path) -> Result<(), space_system::SpaceError> { let path_str = path.display().to_string(); trace!("Creating bucket at: {}", &path_str); - init_bucket_at(path.into()).await?; + init_bucket_at(path.into())?; trace!("Bucket created at: {}", &path_str); Ok(()) } -- cgit