summaryrefslogtreecommitdiff
path: root/rola-bucket
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-06-19 01:40:38 +0800
committer魏曹先生 <1992414357@qq.com>2026-06-19 01:40:38 +0800
commit1e9c97c21f8a4e55420712b054895ff8b4f9a849 (patch)
treec6bd37889deb54c024f974f368a9a7d654cad822 /rola-bucket
parente078163c7cdbbf226c18d3e3afa7268a2878e18b (diff)
feat(rola-bucket): add bucket bind managementHEADmaster
Implement bucket bind CRUD operations and config loading, along with CLI integration for listing, setting, and removing bucket bindings.
Diffstat (limited to 'rola-bucket')
-rw-r--r--rola-bucket/Cargo.toml1
-rw-r--r--rola-bucket/res/bucket.toml3
-rw-r--r--rola-bucket/src/bucket.rs6
-rw-r--r--rola-bucket/src/bucket/bind.rs210
-rw-r--r--rola-bucket/src/bucket/bind/test.rs231
-rw-r--r--rola-bucket/src/bucket/config.rs30
-rw-r--r--rola-bucket/src/bucket/idmap.rs0
-rw-r--r--rola-bucket/src/bucket/init.rs27
-rw-r--r--rola-bucket/src/bucket/space.rs4
9 files changed, 494 insertions, 18 deletions
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<String>) -> 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<Protocol: AsyncBucketTransferProtocol + Send + Sync>(
+ space: &Space<Bucket<Protocol>>,
+) -> Result<Vec<BucketBind>, 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::<u8>() {
+ // 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<Protocol: AsyncBucketTransferProtocol + Send + Sync>(
+ space: &Space<Bucket<Protocol>>,
+ 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<Protocol: AsyncBucketTransferProtocol + Send + Sync>(
+ space: &Space<Bucket<Protocol>>,
+ idx: u8,
+) -> Result<Option<BucketBind>, 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<Protocol: AsyncBucketTransferProtocol + Send + Sync>(
+ space: &Space<Bucket<Protocol>>,
+ idx: u8,
+) -> Result<bool, SpaceError> {
+ 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<Protocol: AsyncBucketTransferProtocol + Send + Sync>(
+ space: &Space<Bucket<Protocol>>,
+ 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<Ordering> {
+ 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<String> 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<Bucket<NoProtocol>> {
+ let bucket = Bucket::<NoProtocol>::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
--- /dev/null
+++ b/rola-bucket/src/bucket/idmap.rs
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<Protocol: AsyncBucketTransferProtocol + Send + Sync> SpaceRoot for Bucket<P
SpaceRootFindPattern::IncludeFile(FILE_BUCKET_ROOT_CONFIG.into())
}
- async fn create_space(path: &std::path::Path) -> 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(())
}