Merge m5max128gitea Web GUI + Backup features with local SMB fixes
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled

Merged from m5max128gitea:
- Web GUI Phase 11: User/Share/Dashboard management
- NFS stub + nfsserve dependency
- Backup/Snapshot REST API endpoints
- Integration tests for user/share management
- Feature comparison docs (Proxmox/Unraid/OpenNAS)

Preserved from local:
- upload_path config (tested stable)
- delete_file/preview_file routes (MyFiles)
- SSH async I/O
- auth.sqlite (important user data)
- Admin WebDAV + CorsLayer

Conflicts resolved:
- AGENTS.md: kept remote (more complete docs)
- myfiles.rs: kept local upload_path
- server.rs: merged both routes (preview + backup)
- auth.sqlite: preserved local (important user data)
This commit is contained in:
Warren
2026-06-30 07:37:34 +08:00
78 changed files with 13559 additions and 735 deletions

View File

@@ -162,7 +162,7 @@ pub async fn handle_webdav_command(cmd: WebdavCommand) -> anyhow::Result<()> {
if folders.is_empty() {
println!("No virtual folders.");
} else {
println!("{:<30} {}", "Folder", "Description");
println!("{:<30} Description", "Folder");
println!("{}", "-".repeat(60));
for (f, d) in folders {
println!("{:<30} {}", f, d);
@@ -254,7 +254,7 @@ async fn run_webdav_server(
let valid = match (auth, expected) {
(Some((u, p)), Some(exp)) => {
u == exp.username && exp.password.as_ref().map_or(true, |exp_p| p == *exp_p)
u == exp.username && exp.password.as_ref().is_none_or(|exp_p| p == *exp_p)
}
_ => false,
};

View File

@@ -1,6 +1,8 @@
pub mod render;
pub mod smb_server;
pub mod test;
#[cfg(feature = "nfs")]
pub mod nfs_server;
use clap::Subcommand;
@@ -12,6 +14,8 @@ pub enum ToolsCommands {
Test(test::TestCommand),
#[command(flatten)]
SmbServer(smb_server::SmbServerCommand),
#[cfg(feature = "nfs")]
Nfs(nfs_server::NfsServerCommand),
}
pub async fn handle_tools_command(cmd: ToolsCommands) -> anyhow::Result<()> {
@@ -19,6 +23,8 @@ pub async fn handle_tools_command(cmd: ToolsCommands) -> anyhow::Result<()> {
ToolsCommands::Render(c) => render::handle_render_command(c)?,
ToolsCommands::Test(c) => test::handle_test_command(c)?,
ToolsCommands::SmbServer(c) => smb_server::handle_smb_server_command(c).await?,
#[cfg(feature = "nfs")]
ToolsCommands::Nfs(c) => nfs_server::run_nfs_server(c).await?,
}
Ok(())
}

View File

@@ -0,0 +1,41 @@
use clap::Args;
use std::path::PathBuf;
use std::sync::Arc;
use crate::vfs::{local_fs::LocalFs, nfs_server::{NfsVfsServer, NfsConfig}};
#[derive(Debug, Args)]
pub struct NfsServerCommand {
/// Port to listen on (default: 2049)
#[arg(short, long, default_value = "2049")]
port: u16,
/// Root directory to export
#[arg(short, long, default_value = "/tmp/nfs_export")]
root: PathBuf,
/// Share name (export name)
#[arg(short, long, default_value = "export")]
share_name: String,
}
pub async fn run_nfs_server(cmd: NfsServerCommand) -> anyhow::Result<()> {
println!("Starting NFS server on port {}", cmd.port);
println!("Export directory: {}", cmd.root.display());
println!("Share name: {}", cmd.share_name);
if !cmd.root.exists() {
std::fs::create_dir_all(&cmd.root)?;
println!("Created export directory: {}", cmd.root.display());
}
let vfs = Arc::new(LocalFs::new());
let server = NfsVfsServer::new(vfs, cmd.root.clone()).with_port(cmd.port);
println!("NFS server starting...");
server.start(cmd.port).await?;
println!("NFS server stopped");
Ok(())
}

View File

@@ -103,21 +103,21 @@ pub async fn handle_smb_server_command(cmd: SmbServerCommand) -> anyhow::Result<
s3_secret_key,
s3_region,
ldap,
ldap_url,
ldap_base_dn,
ldap_bind_dn,
ldap_bind_password,
ldap_user_search_base,
ldap_group_search_base,
ldap_user_id_attr,
ldap_user_filter,
ldap_group_filter,
ldap_home_dir_attr,
ldap_home_dir_prefix,
ldap_user_groups_attr,
ldap_url: _,
ldap_base_dn: _,
ldap_bind_dn: _,
ldap_bind_password: _,
ldap_user_search_base: _,
ldap_group_search_base: _,
ldap_user_id_attr: _,
ldap_user_filter: _,
ldap_group_filter: _,
ldap_home_dir_attr: _,
ldap_home_dir_prefix: _,
ldap_user_groups_attr: _,
} => {
use std::path::PathBuf;
use std::sync::Arc;
use smb_server::{Access, Share, SmbServer};
use tracing_subscriber::EnvFilter;
@@ -164,9 +164,11 @@ pub async fn handle_smb_server_command(cmd: SmbServerCommand) -> anyhow::Result<
user
};
let ldap_provider: Option<Arc<crate::provider::ldap::LdapProvider>> = if ldap {
#[cfg(feature = "ldap")]
{
#[allow(unused_mut)]
let mut ldap_enabled = false;
#[cfg(feature = "ldap")]
{
if ldap {
let config = crate::provider::ldap::LdapConfig {
ldap_url: ldap_url.unwrap_or_else(|| "ldap://localhost:389".to_string()),
base_dn: ldap_base_dn.unwrap_or_else(|| "dc=example,dc=com".to_string()),
@@ -182,16 +184,13 @@ pub async fn handle_smb_server_command(cmd: SmbServerCommand) -> anyhow::Result<
user_groups_attr: ldap_user_groups_attr.unwrap_or_else(|| "memberOf".to_string()),
};
log::info!("LDAP authentication enabled: url={}, search_base={}", config.ldap_url, config.user_search_base);
Some(Arc::new(crate::provider::ldap::LdapProvider::new(config)))
ldap_enabled = true;
}
#[cfg(not(feature = "ldap"))]
{
log::warn!("LDAP authentication requested but ldap feature not enabled");
None
}
} else {
None
};
}
#[cfg(not(feature = "ldap"))]
if ldap {
log::warn!("LDAP authentication requested but ldap feature not enabled");
}
let mut builder = SmbServer::builder().listen(addr);
@@ -210,7 +209,7 @@ pub async fn handle_smb_server_command(cmd: SmbServerCommand) -> anyhow::Result<
log::info!("SMB server listening on {}", addr);
log::info!("Share '{}' at root: {}", share_name, root);
log::info!("Users: {}", user_list.join(", "));
if ldap_provider.is_some() {
if ldap_enabled {
log::info!("LDAP authentication: enabled");
}

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, RwLock};
use std::sync::RwLock;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]

View File

@@ -1,5 +1,4 @@
use byteorder::{BigEndian, LittleEndian, ReadBytesExt, WriteBytesExt};
use std::io::{self, Cursor, Read, Write};
use std::io::{self, Read, Write};
use std::net::TcpStream;
pub const CTDB_MAGIC: u32 = 0x43544442;

View File

@@ -1,4 +1,4 @@
use std::sync::{Arc, RwLock};
use std::sync::RwLock;
use std::time::{Duration, Instant};
use super::ip_manager::IpManager;

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::io::{self, Read, Write, Seek, SeekFrom};
use std::path::Path;
use std::sync::{Arc, Mutex, RwLock};
use std::sync::{Mutex, RwLock};
const TDB_MAGIC: u32 = 0x1BADFACE;
const TDB_VERSION: u32 = 1;

View File

@@ -1,6 +1,7 @@
pub mod pg;
pub mod sqlite;
#[cfg(feature = "ldap")]
#[cfg(feature = "ldap")]
pub mod ldap;
pub use pg::PgProvider;
@@ -72,4 +73,19 @@ pub trait DataProvider: Send + Sync {
let _ = username;
Ok(Vec::new())
}
/// 列出所有用户
fn list_users(&self) -> Result<Vec<User>, ProviderError>;
/// 创建用户
fn create_user(&self, user: &User, password: &str) -> Result<(), ProviderError>;
/// 更新用户
fn update_user(&self, user: &User, new_password: Option<&str>) -> Result<(), ProviderError>;
/// 删除用户
fn delete_user(&self, username: &str) -> Result<(), ProviderError>;
/// 重置密码
fn reset_password(&self, username: &str, new_password: &str) -> Result<(), ProviderError>;
}

View File

@@ -115,6 +115,102 @@ impl DataProvider for PgProvider {
None => Ok(Vec::new()),
}
}
fn list_users(&self) -> Result<Vec<User>, ProviderError> {
let mut conn = self.open_conn()?;
let rows = conn
.query(
"SELECT username, password, home_dir, permissions, uid, gid, status
FROM users ORDER BY username",
&[],
)
.map_err(|e| ProviderError::Internal(format!("Query error: {}", e)))?;
let users = rows
.iter()
.map(|row| User {
username: row.get(0),
password_hash: row.get::<_, Option<String>>(1).unwrap_or_default(),
home_dir: PathBuf::from(row.get::<_, String>(2)),
permissions: row
.get::<_, Option<String>>(3)
.unwrap_or_else(|| "*".to_string()),
uid: row.get::<_, i64>(4) as u32,
gid: row.get::<_, i64>(5) as u32,
status: row.get(6),
})
.collect();
Ok(users)
}
fn create_user(&self, user: &User, password: &str) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
let hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"INSERT INTO users (username, password, home_dir, permissions, uid, gid, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
&[&user.username, &hash, &user.home_dir.to_string_lossy(), &user.permissions, &(user.uid as i64), &(user.gid as i64), &user.status],
)
.map_err(|e| ProviderError::Internal(format!("Insert error: {}", e)))?;
Ok(())
}
fn update_user(&self, user: &User, new_password: Option<&str>) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
if let Some(pwd) = new_password {
let hash = bcrypt::hash(pwd, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE users
SET password = $2, home_dir = $3, permissions = $4, uid = $5, gid = $6, status = $7
WHERE username = $1",
&[&user.username, &hash, &user.home_dir.to_string_lossy(), &user.permissions, &(user.uid as i64), &(user.gid as i64), &user.status],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
} else {
conn.execute(
"UPDATE users
SET home_dir = $2, permissions = $3, uid = $4, gid = $5, status = $6
WHERE username = $1",
&[&user.username, &user.home_dir.to_string_lossy(), &user.permissions, &(user.uid as i64), &(user.gid as i64), &user.status],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
}
Ok(())
}
fn delete_user(&self, username: &str) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
conn.execute("DELETE FROM users WHERE username = $1", &[&username])
.map_err(|e| ProviderError::Internal(format!("Delete error: {}", e)))?;
Ok(())
}
fn reset_password(&self, username: &str, new_password: &str) -> Result<(), ProviderError> {
let mut conn = self.open_conn()?;
let hash = bcrypt::hash(new_password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE users SET password = $2 WHERE username = $1",
&[&username, &hash],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
Ok(())
}
}
#[cfg(test)]

View File

@@ -89,6 +89,123 @@ impl DataProvider for SqliteProvider {
.collect();
Ok(groups)
}
fn list_users(&self) -> Result<Vec<User>, ProviderError> {
let conn = self.open_conn()?;
let users = conn
.prepare(
"SELECT username, password_hash, home_dir, permissions, uid, gid, status
FROM sftpgo_users ORDER BY username",
)
.map_err(|e| ProviderError::Internal(format!("Query prepare error: {}", e)))?
.query_map([], |row| {
Ok(User {
username: row.get(0)?,
password_hash: row.get(1)?,
home_dir: PathBuf::from(row.get::<_, String>(2)?),
permissions: row.get(3)?,
uid: row.get::<_, i64>(4)? as u32,
gid: row.get::<_, i64>(5)? as u32,
status: row.get(6)?,
})
})
.map_err(|e| ProviderError::Internal(format!("Query map error: {}", e)))?
.filter_map(|r| r.ok())
.collect();
Ok(users)
}
fn create_user(&self, user: &User, password: &str) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
let hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"INSERT INTO sftpgo_users (username, password_hash, home_dir, permissions, uid, gid, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
user.username,
hash,
user.home_dir.to_string_lossy(),
user.permissions,
user.uid as i64,
user.gid as i64,
user.status,
],
)
.map_err(|e| ProviderError::Internal(format!("Insert error: {}", e)))?;
Ok(())
}
fn update_user(&self, user: &User, new_password: Option<&str>) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
if let Some(pwd) = new_password {
let hash = bcrypt::hash(pwd, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE sftpgo_users
SET password_hash = ?2, home_dir = ?3, permissions = ?4, uid = ?5, gid = ?6, status = ?7
WHERE username = ?1",
params![
user.username,
hash,
user.home_dir.to_string_lossy(),
user.permissions,
user.uid as i64,
user.gid as i64,
user.status,
],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
} else {
conn.execute(
"UPDATE sftpgo_users
SET home_dir = ?2, permissions = ?3, uid = ?4, gid = ?5, status = ?6
WHERE username = ?1",
params![
user.username,
user.home_dir.to_string_lossy(),
user.permissions,
user.uid as i64,
user.gid as i64,
user.status,
],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
}
Ok(())
}
fn delete_user(&self, username: &str) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
conn.execute("DELETE FROM sftpgo_users WHERE username = ?1", params![username])
.map_err(|e| ProviderError::Internal(format!("Delete error: {}", e)))?;
Ok(())
}
fn reset_password(&self, username: &str, new_password: &str) -> Result<(), ProviderError> {
let conn = self.open_conn()?;
let hash = bcrypt::hash(new_password, bcrypt::DEFAULT_COST)
.map_err(|e| ProviderError::Internal(format!("bcrypt hash error: {}", e)))?;
conn.execute(
"UPDATE sftpgo_users SET password_hash = ?2 WHERE username = ?1",
params![username, hash],
)
.map_err(|e| ProviderError::Internal(format!("Update error: {}", e)))?;
Ok(())
}
}
#[cfg(test)]

View File

@@ -87,7 +87,7 @@ pub async fn list_objects(
pub async fn get_object(
Path((bucket, key)): Path<(String, String)>,
State(state): State<crate::server::AppState>,
State(_state): State<crate::server::AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
println!("S3 GET Object: bucket={}, key={}", bucket, key);
@@ -174,7 +174,7 @@ pub async fn get_object(
pub async fn put_object(
Path((bucket, key)): Path<(String, String)>,
State(state): State<crate::server::AppState>,
State(_state): State<crate::server::AppState>,
headers: HeaderMap,
body: Body,
) -> impl IntoResponse {
@@ -378,7 +378,7 @@ pub async fn generate_s3_key(State(state): State<crate::server::AppState>) -> im
pub async fn delete_object(
Path((bucket, key)): Path<(String, String)>,
State(state): State<crate::server::AppState>,
State(_state): State<crate::server::AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
println!("S3 DELETE Object: bucket={}, key={}", bucket, key);
@@ -606,7 +606,7 @@ static MULTIPART_UPLOADS: once_cell::sync::Lazy<Arc<RwLock<HashMap<String, Multi
pub async fn initiate_multipart_upload(
Path((bucket, key)): Path<(String, String)>,
State(state): State<crate::server::AppState>,
State(_state): State<crate::server::AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
// Authentication check
@@ -641,7 +641,7 @@ pub async fn initiate_multipart_upload(
pub async fn upload_part(
Path((bucket, key)): Path<(String, String)>,
State(state): State<crate::server::AppState>,
State(_state): State<crate::server::AppState>,
query: axum::extract::Query<UploadPartQuery>,
headers: HeaderMap,
body: Body,
@@ -732,7 +732,7 @@ pub struct UploadPartQuery {
pub async fn complete_multipart_upload(
Path((bucket, key)): Path<(String, String)>,
State(state): State<crate::server::AppState>,
State(_state): State<crate::server::AppState>,
query: axum::extract::Query<CompleteMultipartQuery>,
headers: HeaderMap,
body: Body,
@@ -835,7 +835,7 @@ pub struct CompleteMultipartQuery {
pub async fn abort_multipart_upload(
Path((bucket, key)): Path<(String, String)>,
State(state): State<crate::server::AppState>,
State(_state): State<crate::server::AppState>,
query: axum::extract::Query<AbortMultipartQuery>,
headers: HeaderMap,
) -> impl IntoResponse {

View File

@@ -9,7 +9,7 @@ use axum::{
Router,
};
use base64::Engine as _;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::sync::{Arc, LazyLock, Mutex, OnceLock};
use std::time::{Duration, Instant};
@@ -340,6 +340,14 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
.route("/api/v2/myfiles/:username/tags", post(crate::myfiles::add_tag).delete(crate::myfiles::remove_tag))
.route("/api/v2/myfiles/:username/files/:filename/tags", get(crate::myfiles::file_tags))
.route("/api/v2/myfiles/:username/preview/:filename", get(crate::myfiles::preview_file))
// Backup/Snapshot API endpoints (Phase 5-6)
.route("/api/v2/backup/stats", get(get_backup_stats_handler))
.route("/api/v2/backup/config", get(get_backup_config_handler).post(set_backup_config_handler))
.route("/api/v2/backup/run", post(run_backup_handler))
.route("/api/v2/snapshots", get(list_snapshots_handler))
.route("/api/v2/snapshots/:name", post(create_snapshot_handler).delete(delete_snapshot_handler))
.route("/api/v2/snapshots/:name/restore", post(restore_snapshot_handler))
.route("/api/v2/storage/stats", get(get_storage_stats_handler))
.layer(Extension(webdav_parent))
.layer(Extension(upload_hook))
.layer(Extension(webdav_versioning))
@@ -2335,7 +2343,7 @@ static ADMIN_WEBDAV_HANDLER: LazyLock<Option<dav_server::DavHandler>> = LazyLock
});
async fn handle_webdav_admin(
Extension(upload_hook): Extension<Arc<crate::ssh_server::upload_hook::UploadHook>>,
Extension(_upload_hook): Extension<Arc<crate::ssh_server::upload_hook::UploadHook>>,
req: axum::extract::Request,
) -> axum::response::Response {
let admin_users = std::env::var("MB_WEBDAV_ADMIN_USERS")
@@ -2395,3 +2403,182 @@ async fn handle_webdav_admin(
let axum_body = axum::body::Body::from_stream(body);
axum::response::Response::from_parts(parts, axum_body)
}
// ============================================================================
// Backup/Snapshot API Handlers (Phase 5-6)
// ============================================================================
use crate::vfs::{VfsBackend, local_fs::LocalFs, backup_scheduler::{BackupScheduler, BackupScheduleConfig}};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupStatsResponse {
pub enabled: bool,
pub backup_count: usize,
pub last_backup: Option<u64>,
pub next_backup: Option<u64>,
pub interval_hours: u64,
pub max_snapshots: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupConfigResponse {
pub enabled: bool,
pub interval_hours: u64,
pub max_snapshots: usize,
pub auto_cleanup: bool,
pub compress: String,
pub encrypt: bool,
pub include_checksums: bool,
pub incremental: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageStatsResponse {
pub total_size: u64,
pub used_size: u64,
pub free_size: u64,
pub dedup_ratio: f64,
pub compression_ratio: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SnapshotResponse {
pub name: String,
}
static BACKUP_SCHEDULER: LazyLock<std::sync::Arc<std::sync::Mutex<BackupScheduler>>> =
LazyLock::new(|| {
let backend = Arc::new(LocalFs::new()) as Arc<dyn VfsBackend>;
std::sync::Arc::new(std::sync::Mutex::new(
BackupScheduler::new(backend, PathBuf::from("/data"), BackupScheduleConfig::default())
))
});
async fn get_backup_stats_handler() -> Json<BackupStatsResponse> {
let scheduler = BACKUP_SCHEDULER.lock().unwrap();
let stats = scheduler.get_stats();
Json(BackupStatsResponse {
enabled: stats.enabled,
backup_count: stats.backup_count,
last_backup: stats.last_backup,
next_backup: stats.next_backup,
interval_hours: stats.interval_hours,
max_snapshots: stats.max_snapshots,
})
}
async fn get_backup_config_handler() -> Json<BackupConfigResponse> {
let scheduler = BACKUP_SCHEDULER.lock().unwrap();
let config = scheduler.get_config();
let compress_name = match config.compress {
crate::vfs::VfsCompression::None => "none",
crate::vfs::VfsCompression::Lz4 => "lz4",
crate::vfs::VfsCompression::Zstd => "zstd",
};
Json(BackupConfigResponse {
enabled: config.enabled,
interval_hours: config.interval_hours,
max_snapshots: config.max_snapshots,
auto_cleanup: config.auto_cleanup,
compress: compress_name.to_string(),
encrypt: config.encrypt,
include_checksums: config.include_checksums,
incremental: config.incremental,
})
}
async fn set_backup_config_handler(Json(config): Json<BackupConfigResponse>) -> Json<serde_json::Value> {
let mut scheduler = BACKUP_SCHEDULER.lock().unwrap();
let compress = match config.compress.as_str() {
"lz4" => crate::vfs::VfsCompression::Lz4,
"zstd" => crate::vfs::VfsCompression::Zstd,
_ => crate::vfs::VfsCompression::None,
};
let new_config = BackupScheduleConfig {
enabled: config.enabled,
interval_hours: config.interval_hours,
max_snapshots: config.max_snapshots,
auto_cleanup: config.auto_cleanup,
compress,
encrypt: config.encrypt,
include_checksums: config.include_checksums,
incremental: config.incremental,
};
scheduler.set_config(new_config);
Json(serde_json::json!({"success": true, "message": "Backup config updated"}))
}
async fn run_backup_handler() -> Json<serde_json::Value> {
let mut scheduler = BACKUP_SCHEDULER.lock().unwrap();
match scheduler.run_backup() {
Ok(name) => Json(serde_json::json!({"success": true, "snapshot_name": name})),
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
}
}
async fn list_snapshots_handler(Query(params): Query<std::collections::HashMap<String, String>>) -> Json<Vec<String>> {
let root = params.get("root").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/data"));
let backend = LocalFs::new();
match backend.list_snapshots(&root) {
Ok(list) => Json(list),
Err(_) => Json(Vec::new()),
}
}
async fn create_snapshot_handler(
Path(name): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Json<serde_json::Value> {
let root = params.get("root").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/data"));
let backend = LocalFs::new();
match backend.create_snapshot(&root, &name) {
Ok(_) => Json(serde_json::json!({"success": true, "name": name})),
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
}
}
async fn delete_snapshot_handler(
Path(name): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Json<serde_json::Value> {
let root = params.get("root").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/data"));
let backend = LocalFs::new();
match backend.delete_snapshot(&root, &name) {
Ok(_) => Json(serde_json::json!({"success": true, "name": name})),
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
}
}
async fn restore_snapshot_handler(
Path(name): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Json<serde_json::Value> {
let root = params.get("root").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/data"));
let backend = LocalFs::new();
match backend.restore_snapshot(&root, &name) {
Ok(_) => Json(serde_json::json!({"success": true, "name": name})),
Err(e) => Json(serde_json::json!({"success": false, "error": e.to_string()})),
}
}
async fn get_storage_stats_handler(Query(params): Query<std::collections::HashMap<String, String>>) -> Json<StorageStatsResponse> {
let root = params.get("root").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/data"));
let backend = LocalFs::new();
match backend.stat(&root) {
Ok(stat) => Json(StorageStatsResponse {
total_size: stat.size,
used_size: stat.size / 2,
free_size: stat.size / 2,
dedup_ratio: 1.0,
compression_ratio: 1.0,
}),
Err(_) => Json(StorageStatsResponse {
total_size: 0,
used_size: 0,
free_size: 0,
dedup_ratio: 1.0,
compression_ratio: 1.0,
}),
}
}

View File

@@ -4,7 +4,7 @@
//! Based on OpenSSH AllowTcpForwarding, PermitOpen, PermitListen directives.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, RwLock};
/// Forward rule type

View File

@@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
use log::{info, warn};
use std::fs;
use std::io::{BufRead, BufReader};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]

View File

@@ -0,0 +1,267 @@
//! Backup Manifest - Snapshot metadata serialization
//!
//! Compatible with ZFS send/receive and Proxmox Backup Server format
use std::path::PathBuf;
use serde::{Serialize, Deserialize};
use sha2::Digest;
use super::{VfsCompression};
use super::checksum::VfsChecksumFile;
use super::dedup::DedupManifest;
pub const MANIFEST_VERSION: u32 = 1;
pub const MANIFEST_FILE: &str = ".manifest.json";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SendFormat {
#[serde(rename = "zfs_compatible")]
ZfsCompatible,
#[serde(rename = "custom_json")]
CustomJson,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupFileEntry {
pub path: String,
pub size: u64,
pub checksums: Option<VfsChecksumFile>,
pub dedup_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptionInfo {
pub algorithm: String,
pub enabled: bool,
pub key_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressionInfo {
pub algorithm: String,
pub level: u32,
pub original_size: u64,
pub compressed_size: u64,
pub ratio: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupManifest {
pub version: u32,
pub format: SendFormat,
pub snapshot_name: String,
pub created_at: u64,
pub root_path: String,
pub files: Vec<BackupFileEntry>,
pub dedup_manifest: Option<DedupManifest>,
pub encryption: Option<EncryptionInfo>,
pub compression: Option<CompressionInfo>,
pub total_size: u64,
pub stored_size: u64,
pub overall_ratio: f64,
}
impl BackupManifest {
pub fn new(snapshot_name: String, root_path: PathBuf) -> Self {
Self {
version: MANIFEST_VERSION,
format: SendFormat::CustomJson,
snapshot_name,
created_at: current_time_secs(),
root_path: root_path.to_string_lossy().to_string(),
files: Vec::new(),
dedup_manifest: None,
encryption: None,
compression: None,
total_size: 0,
stored_size: 0,
overall_ratio: 1.0,
}
}
pub fn add_file(&mut self, path: String, size: u64, checksums: Option<VfsChecksumFile>) {
self.files.push(BackupFileEntry {
path,
size,
checksums,
dedup_hash: None,
});
self.total_size += size;
}
pub fn set_dedup(&mut self, manifest: DedupManifest) {
self.dedup_manifest = Some(manifest.clone());
if manifest.original_size > 0 {
let stored = (manifest.block_hashes.len() as u64) * 4096; // Approximate
self.stored_size = stored;
}
}
pub fn set_compression(&mut self, algorithm: VfsCompression, original: u64, compressed: u64) {
let ratio = if original > 0 { compressed as f64 / original as f64 } else { 1.0 };
self.compression = Some(CompressionInfo {
algorithm: algorithm_name(&algorithm),
level: 3,
original_size: original,
compressed_size: compressed,
ratio,
});
}
pub fn set_encryption(&mut self, enabled: bool, key_hash: Option<String>) {
self.encryption = Some(EncryptionInfo {
algorithm: "AES-256-GCM".to_string(),
enabled,
key_hash,
});
}
pub fn calculate_ratio(&mut self) {
if self.total_size > 0 && self.stored_size > 0 {
self.overall_ratio = self.stored_size as f64 / self.total_size as f64;
}
}
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec(self).map_err(|e| e.to_string())
}
pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
serde_json::from_slice(data).map_err(|e| e.to_string())
}
pub fn save(&self, snapshot_dir: &PathBuf) -> Result<(), String> {
let manifest_path = snapshot_dir.join(MANIFEST_FILE);
let data = self.to_bytes()?;
std::fs::write(&manifest_path, data).map_err(|e| e.to_string())
}
pub fn load(snapshot_dir: &PathBuf) -> Result<Self, String> {
let manifest_path = snapshot_dir.join(MANIFEST_FILE);
let data = std::fs::read(&manifest_path).map_err(|e| e.to_string())?;
Self::from_bytes(&data)
}
}
fn algorithm_name(compression: &VfsCompression) -> String {
match compression {
VfsCompression::None => "none".to_string(),
VfsCompression::Lz4 => "lz4".to_string(),
VfsCompression::Zstd => "zstd".to_string(),
}
}
fn current_time_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub struct BackupStream {
pub format: SendFormat,
pub manifest: BackupManifest,
pub data: Vec<u8>,
}
impl BackupStream {
pub fn new(format: SendFormat, manifest: BackupManifest, data: Vec<u8>) -> Self {
Self { format, manifest, data }
}
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
match self.format {
SendFormat::CustomJson => {
let manifest_bytes = self.manifest.to_bytes()?;
let mut result = Vec::new();
result.extend_from_slice(&manifest_bytes.len().to_be_bytes());
result.extend_from_slice(&manifest_bytes);
result.extend_from_slice(&self.data);
Ok(result)
}
SendFormat::ZfsCompatible => {
Err("ZFS compatible format not yet implemented".to_string())
}
}
}
pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
if data.len() < 8 {
return Err("Stream too short".to_string());
}
let manifest_len = u64::from_be_bytes(data[0..8].try_into().map_err(|_| "Invalid length")?) as usize;
if data.len() < 8 + manifest_len {
return Err("Stream truncated".to_string());
}
let manifest_bytes = &data[8..8 + manifest_len];
let manifest = BackupManifest::from_bytes(manifest_bytes)?;
let payload = data[8 + manifest_len..].to_vec();
Ok(Self::new(manifest.format.clone(), manifest, payload))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_creation() {
let manifest = BackupManifest::new("snap_2026-06-24".to_string(), PathBuf::from("/data"));
assert_eq!(manifest.version, MANIFEST_VERSION);
assert_eq!(manifest.format, SendFormat::CustomJson);
assert_eq!(manifest.snapshot_name, "snap_2026-06-24");
}
#[test]
fn test_manifest_serialization() {
let mut manifest = BackupManifest::new("test_snap".to_string(), PathBuf::from("/data"));
manifest.add_file("file1.txt".to_string(), 1024, None);
manifest.add_file("file2.txt".to_string(), 2048, None);
manifest.calculate_ratio();
let bytes = manifest.to_bytes().unwrap();
let decoded = BackupManifest::from_bytes(&bytes).unwrap();
assert_eq!(decoded.files.len(), 2);
assert_eq!(decoded.total_size, 3072);
}
#[test]
fn test_backup_stream_roundtrip() {
let manifest = BackupManifest::new("test".to_string(), PathBuf::from("/"));
let stream = BackupStream::new(SendFormat::CustomJson, manifest, b"test data".to_vec());
let bytes = stream.to_bytes().unwrap();
let decoded = BackupStream::from_bytes(&bytes).unwrap();
assert_eq!(decoded.data, b"test data");
}
#[test]
fn test_compression_info() {
let mut manifest = BackupManifest::new("test".to_string(), PathBuf::from("/"));
manifest.set_compression(VfsCompression::Zstd, 1000, 420);
assert!(manifest.compression.is_some());
let comp = manifest.compression.unwrap();
assert_eq!(comp.algorithm, "zstd");
assert_eq!(comp.ratio, 0.42);
}
#[test]
fn test_encryption_info() {
let mut manifest = BackupManifest::new("test".to_string(), PathBuf::from("/"));
manifest.set_encryption(true, Some("key_hash_abc".to_string()));
assert!(manifest.encryption.is_some());
let enc = manifest.encryption.unwrap();
assert!(enc.enabled);
assert_eq!(enc.algorithm, "AES-256-GCM");
}
}

View File

@@ -0,0 +1,630 @@
//! Backup Scheduler - Automated snapshot creation
//!
//! Similar to Proxmox Backup Server scheduling
use std::sync::Arc;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::TimeZone;
use super::{VfsBackend, VfsError, VfsCompression};
pub struct BackupScheduleConfig {
pub enabled: bool,
pub interval_hours: u64,
pub max_snapshots: usize,
pub auto_cleanup: bool,
pub compress: VfsCompression,
pub encrypt: bool,
pub include_checksums: bool,
pub incremental: bool,
}
impl Default for BackupScheduleConfig {
fn default() -> Self {
Self {
enabled: true,
interval_hours: 24,
max_snapshots: 7,
auto_cleanup: true,
compress: VfsCompression::Zstd,
encrypt: false,
include_checksums: true,
incremental: true,
}
}
}
pub struct BackupScheduler {
backend: Arc<dyn VfsBackend>,
root: PathBuf,
config: BackupScheduleConfig,
last_backup: Option<u64>,
next_backup: Option<u64>,
backup_count: usize,
snapshots: Vec<String>,
}
impl BackupScheduler {
pub fn new(
backend: Arc<dyn VfsBackend>,
root: PathBuf,
config: BackupScheduleConfig,
) -> Self {
Self {
backend,
root,
config,
last_backup: None,
next_backup: None,
backup_count: 0,
snapshots: Vec::new(),
}
}
pub fn with_defaults(backend: Arc<dyn VfsBackend>, root: PathBuf) -> Self {
Self::new(backend, root, BackupScheduleConfig::default())
}
pub fn start(&mut self) {
self.config.enabled = true;
self.schedule_next();
}
pub fn stop(&mut self) {
self.config.enabled = false;
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn get_config(&self) -> &BackupScheduleConfig {
&self.config
}
pub fn set_config(&mut self, config: BackupScheduleConfig) {
self.config = config;
if self.config.enabled {
self.schedule_next();
}
}
pub fn schedule_next(&mut self) {
let now = current_time_secs();
let interval_secs = self.config.interval_hours * 3600;
if let Some(last) = self.last_backup {
self.next_backup = Some(last + interval_secs);
} else {
self.next_backup = Some(now + interval_secs);
}
}
pub fn should_run(&self) -> bool {
if !self.config.enabled {
return false;
}
let now = current_time_secs();
match self.next_backup {
None => true,
Some(next) => now >= next,
}
}
pub fn run_backup(&mut self) -> Result<String, VfsError> {
if !self.config.enabled {
return Err(VfsError::Io("Backup scheduler is disabled".to_string()));
}
let name = generate_snapshot_name();
let snapshot_dir = self.root.join(".snapshots").join(&name);
self.backend.create_dir(&snapshot_dir, 0o755)?;
if self.config.incremental && !self.snapshots.is_empty() {
let base_snapshot = self.snapshots.last().unwrap();
self.copy_incremental_to_snapshot(base_snapshot, &snapshot_dir)?;
} else {
self.copy_root_to_snapshot(&snapshot_dir)?;
}
if self.config.include_checksums {
self.generate_checksums(&snapshot_dir)?;
}
if self.config.auto_cleanup {
self.cleanup_old_snapshots()?;
}
self.last_backup = Some(current_time_secs());
self.backup_count += 1;
self.snapshots.push(name.clone());
self.schedule_next();
Ok(name)
}
fn copy_incremental_to_snapshot(&self, base: &str, snapshot_dir: &PathBuf) -> Result<(), VfsError> {
let base_dir = self.root.join(".snapshots").join(base);
if !self.backend.exists(&base_dir) {
return self.copy_root_to_snapshot(snapshot_dir);
}
let entries = self.backend.read_dir(&self.root)?;
for entry in entries {
if entry.name == ".snapshots" || entry.name == ".checksums" {
continue;
}
let src_path = self.root.join(&entry.name);
let dst_path = snapshot_dir.join(&entry.name);
let base_path = base_dir.join(&entry.name);
if entry.stat.is_dir {
self.copy_directory_incremental(&src_path, &dst_path, &base_path)?;
} else {
let needs_copy = !self.backend.exists(&base_path) ||
self.file_changed(&src_path, &base_path)?;
if needs_copy {
self.copy_file(&src_path, &dst_path)?;
} else {
self.create_hard_link(&base_path, &dst_path)?;
}
}
}
Ok(())
}
fn file_changed(&self, src: &PathBuf, base: &PathBuf) -> Result<bool, VfsError> {
let src_stat = self.backend.stat(src)?;
let base_stat = self.backend.stat(base)?;
Ok(src_stat.size != base_stat.size ||
src_stat.mtime != base_stat.mtime)
}
fn create_hard_link(&self, src: &PathBuf, dst: &PathBuf) -> Result<(), VfsError> {
self.backend.hard_link(src, dst)
}
fn copy_directory_incremental(&self, src: &PathBuf, dst: &PathBuf, base: &PathBuf) -> Result<(), VfsError> {
self.backend.create_dir(dst, 0o755)?;
let entries = self.backend.read_dir(src)?;
for entry in entries {
let child_src = src.join(&entry.name);
let child_dst = dst.join(&entry.name);
let child_base = base.join(&entry.name);
if entry.stat.is_dir {
self.copy_directory_incremental(&child_src, &child_dst, &child_base)?;
} else {
let needs_copy = !self.backend.exists(&child_base) ||
self.file_changed(&child_src, &child_base)?;
if needs_copy {
self.copy_file(&child_src, &child_dst)?;
} else {
self.create_hard_link(&child_base, &child_dst)?;
}
}
}
Ok(())
}
fn copy_root_to_snapshot(&self, snapshot_dir: &PathBuf) -> Result<(), VfsError> {
let entries = self.backend.read_dir(&self.root)?;
for entry in entries {
if entry.name == ".snapshots" || entry.name == ".checksums" {
continue;
}
let src_path = self.root.join(&entry.name);
let dst_path = snapshot_dir.join(&entry.name);
if entry.stat.is_dir {
self.copy_directory(&src_path, &dst_path)?;
} else {
self.copy_file(&src_path, &dst_path)?;
}
}
Ok(())
}
fn copy_directory(&self, src: &PathBuf, dst: &PathBuf) -> Result<(), VfsError> {
self.backend.create_dir(dst, 0o755)?;
let entries = self.backend.read_dir(src)?;
for entry in entries {
let src_path = src.join(&entry.name);
let dst_path = dst.join(&entry.name);
if entry.stat.is_dir {
self.copy_directory(&src_path, &dst_path)?;
} else {
self.copy_file(&src_path, &dst_path)?;
}
}
Ok(())
}
fn copy_file(&self, src: &PathBuf, dst: &PathBuf) -> Result<(), VfsError> {
use super::compression::Compressor;
use super::VfsCompressionConfig;
let mut src_file = self.backend.open_file(src, &super::open_flags::OpenFlags::new().read())?;
let data = src_file.read_all()?;
let final_data = if self.config.compress != super::VfsCompression::None {
let compressor = Compressor::new(VfsCompressionConfig {
algorithm: self.config.compress,
min_size: 1024,
level: 3,
});
compressor.compress(&data)?
} else {
data
};
let mut dst_file = self.backend.open_file(
dst,
&super::open_flags::OpenFlags::new().write().create().truncate(),
)?;
dst_file.write_all(&final_data)?;
dst_file.flush()?;
Ok(())
}
fn generate_checksums(&self, snapshot_dir: &PathBuf) -> Result<(), VfsError> {
use super::checksum::create_checksums_for_file;
let entries = self.backend.read_dir(snapshot_dir)?;
for entry in entries {
if entry.name == ".manifest.json" || entry.name == ".meta" || entry.name == ".checksums" {
continue;
}
let file_path = snapshot_dir.join(&entry.name);
if entry.stat.is_dir {
self.generate_checksums_recursive(&file_path, snapshot_dir)?;
} else {
create_checksums_for_file(self.backend.as_ref(), &file_path, snapshot_dir)?;
}
}
Ok(())
}
fn generate_checksums_recursive(
&self,
dir: &PathBuf,
snapshot_dir: &PathBuf,
) -> Result<(), VfsError> {
use super::checksum::create_checksums_for_file;
let entries = self.backend.read_dir(dir)?;
for entry in entries {
let file_path = dir.join(&entry.name);
if entry.stat.is_dir {
self.generate_checksums_recursive(&file_path, snapshot_dir)?;
} else {
create_checksums_for_file(self.backend.as_ref(), &file_path, snapshot_dir)?;
}
}
Ok(())
}
fn cleanup_old_snapshots(&mut self) -> Result<(), VfsError> {
let snapshots_dir = self.root.join(".snapshots");
if !self.backend.exists(&snapshots_dir) {
return Ok(());
}
let entries = self.backend.read_dir(&snapshots_dir)?;
let mut snapshot_names: Vec<String> = entries
.iter()
.filter(|e| e.stat.is_dir && e.name != ".checksums")
.map(|e| e.name.clone())
.collect();
snapshot_names.sort();
while snapshot_names.len() > self.config.max_snapshots {
let oldest = snapshot_names.remove(0);
let oldest_dir = snapshots_dir.join(&oldest);
self.remove_directory_recursive(&oldest_dir)?;
self.snapshots.retain(|s| s != &oldest);
}
Ok(())
}
fn remove_directory_recursive(&self, dir: &PathBuf) -> Result<(), VfsError> {
if !self.backend.exists(dir) {
return Ok(());
}
let entries = self.backend.read_dir(dir)?;
for entry in entries {
let path = dir.join(&entry.name);
if entry.stat.is_dir {
self.remove_directory_recursive(&path)?;
} else {
self.backend.remove_file(&path)?;
}
}
self.backend.remove_dir(dir)?;
Ok(())
}
pub fn list_backups(&self) -> Result<Vec<BackupInfo>, VfsError> {
let snapshots_dir = self.root.join(".snapshots");
if !self.backend.exists(&snapshots_dir) {
return Ok(Vec::new());
}
let entries = self.backend.read_dir(&snapshots_dir)?;
let mut backups = Vec::new();
for entry in entries {
if !entry.stat.is_dir || entry.name == ".checksums" {
continue;
}
let snapshot_dir = snapshots_dir.join(&entry.name);
let info = self.get_backup_info(&entry.name, &snapshot_dir)?;
backups.push(info);
}
backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(backups)
}
fn get_backup_info(&self, name: &str, snapshot_dir: &PathBuf) -> Result<BackupInfo, VfsError> {
let manifest_path = snapshot_dir.join(".manifest.json");
let created_at = if self.backend.exists(&manifest_path) {
let mut file = self.backend.open_file(&manifest_path, &super::open_flags::OpenFlags::new().read())?;
let data = file.read_all()?;
if let Ok(manifest) = super::backup_manifest::BackupManifest::from_bytes(&data) {
manifest.created_at
} else {
current_time_secs()
}
} else {
current_time_secs()
};
let size = self.calculate_snapshot_size(snapshot_dir)?;
Ok(BackupInfo {
name: name.to_string(),
created_at,
size,
checksum_verified: false,
compressed: self.config.compress != VfsCompression::None,
encrypted: self.config.encrypt,
})
}
fn calculate_snapshot_size(&self, dir: &PathBuf) -> Result<u64, VfsError> {
let mut total_size = 0u64;
let entries = self.backend.read_dir(dir)?;
for entry in entries {
let path = dir.join(&entry.name);
if entry.stat.is_dir {
total_size += self.calculate_snapshot_size(&path)?;
} else {
total_size += entry.stat.size;
}
}
Ok(total_size)
}
pub fn get_stats(&self) -> BackupStats {
BackupStats {
enabled: self.config.enabled,
backup_count: self.backup_count,
last_backup: self.last_backup,
next_backup: self.next_backup,
interval_hours: self.config.interval_hours,
max_snapshots: self.config.max_snapshots,
}
}
}
fn generate_snapshot_name() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let datetime = chrono::Utc.timestamp_opt(now as i64, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d_%H%M%S").to_string())
.unwrap_or_else(|| format!("{}", now));
format!("snap_{}", datetime)
}
fn current_time_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub struct BackupInfo {
pub name: String,
pub created_at: u64,
pub size: u64,
pub checksum_verified: bool,
pub compressed: bool,
pub encrypted: bool,
}
impl BackupInfo {
pub fn format_created(&self) -> String {
chrono::Utc.timestamp_opt(self.created_at as i64, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| format!("{} seconds since epoch", self.created_at))
}
pub fn format_size(&self) -> String {
if self.size < 1024 {
format!("{} B", self.size)
} else if self.size < 1024 * 1024 {
format!("{:.2} KB", self.size as f64 / 1024.0)
} else if self.size < 1024 * 1024 * 1024 {
format!("{:.2} MB", self.size as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GB", self.size as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
}
#[derive(Debug, Clone)]
pub struct BackupStats {
pub enabled: bool,
pub backup_count: usize,
pub last_backup: Option<u64>,
pub next_backup: Option<u64>,
pub interval_hours: u64,
pub max_snapshots: usize,
}
impl BackupStats {
pub fn next_backup_in_secs(&self) -> Option<u64> {
if !self.enabled {
return None;
}
let now = current_time_secs();
let next = self.next_backup?;
if next > now {
Some(next - now)
} else {
Some(0)
}
}
pub fn format_last_backup(&self) -> String {
match self.last_backup {
None => "Never".to_string(),
Some(t) => chrono::Utc.timestamp_opt(t as i64, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| format!("{} seconds since epoch", t)),
}
}
pub fn format_next_backup(&self) -> String {
match self.next_backup {
None => "Not scheduled".to_string(),
Some(t) => chrono::Utc.timestamp_opt(t as i64, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| format!("{} seconds since epoch", t)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = BackupScheduleConfig::default();
assert!(config.enabled);
assert_eq!(config.interval_hours, 24);
assert_eq!(config.max_snapshots, 7);
assert!(config.auto_cleanup);
}
#[test]
fn test_scheduler_creation() {
let backend: Arc<dyn VfsBackend> = Arc::new(super::super::local_fs::LocalFs::new());
let scheduler = BackupScheduler::with_defaults(backend, PathBuf::from("/tmp"));
assert!(scheduler.is_enabled());
}
#[test]
fn test_schedule_next() {
let backend: Arc<dyn VfsBackend> = Arc::new(super::super::local_fs::LocalFs::new());
let mut scheduler = BackupScheduler::with_defaults(backend, PathBuf::from("/tmp"));
scheduler.schedule_next();
assert!(scheduler.next_backup.is_some());
}
#[test]
fn test_backup_info_format() {
let info = BackupInfo {
name: "snap_test".to_string(),
created_at: 1719234567,
size: 1536,
checksum_verified: true,
compressed: true,
encrypted: false,
};
assert!(info.format_created().contains("2024"));
assert!(info.format_size().contains("KB"));
}
#[test]
fn test_backup_stats() {
let now = current_time_secs();
let stats = BackupStats {
enabled: true,
backup_count: 5,
last_backup: Some(now - 3600),
next_backup: Some(now + 3600),
interval_hours: 24,
max_snapshots: 7,
};
assert!(stats.enabled);
assert_eq!(stats.backup_count, 5);
assert!(stats.next_backup_in_secs().unwrap_or(0) > 0);
}
#[test]
fn test_snapshot_name_generation() {
let name = generate_snapshot_name();
assert!(name.starts_with("snap_"));
assert!(name.len() > "snap_".len());
}
}

View File

@@ -0,0 +1,448 @@
//! Block-level Checksum for Data Integrity
//!
//! Reference: ZFS/Btrfs checksum verification
//! - ZFS: Fletcher4/SHA256 per-block checksum
//! - Btrfs: CRC32C per-block checksum
//!
//! MarkBase uses SHA-256 (32 bytes) per 4KB block for integrity verification.
use std::path::PathBuf;
use std::io::{Read, Write};
use sha2::{Sha256, Digest};
use serde::{Serialize, Deserialize};
use super::{VfsBackend, VfsFile, VfsError};
pub const BLOCK_SIZE: usize = 4096;
pub const HASH_SIZE: usize = 32; // SHA-256
pub const CHECKSUM_DIR: &str = ".checksums";
pub const CHECKSUM_EXT: &str = ".checksums";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VfsBlockChecksum {
pub offset: u64, // Block offset (multiple of BLOCK_SIZE)
pub hash: Vec<u8>, // SHA-256 hash (32 bytes)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VfsChecksumFile {
pub block_size: usize,
pub algorithm: String, // "sha256"
pub blocks: Vec<VfsBlockChecksum>,
pub file_size: u64, // Original file size
}
impl VfsChecksumFile {
pub fn new(file_size: u64) -> Self {
Self {
block_size: BLOCK_SIZE,
algorithm: "sha256".to_string(),
blocks: Vec::new(),
file_size,
}
}
pub fn from_bytes(data: &[u8]) -> Result<Self, VfsError> {
serde_json::from_slice(data)
.map_err(|e| VfsError::Io(format!("checksum parse failed: {}", e)))
}
pub fn to_bytes(&self) -> Result<Vec<u8>, VfsError> {
serde_json::to_vec(self)
.map_err(|e| VfsError::Io(format!("checksum serialize failed: {}", e)))
}
pub fn get_checksum(&self, offset: u64) -> Option<&[u8]> {
self.blocks.iter()
.find(|b| b.offset == offset)
.map(|b| b.hash.as_slice())
}
pub fn set_checksum(&mut self, offset: u64, hash: Vec<u8>) {
if let Some(block) = self.blocks.iter_mut().find(|b| b.offset == offset) {
block.hash = hash;
} else {
self.blocks.push(VfsBlockChecksum { offset, hash });
self.blocks.sort_by_key(|b| b.offset);
}
}
pub fn block_count(&self) -> usize {
(self.file_size as usize / BLOCK_SIZE) +
if !(self.file_size as usize).is_multiple_of(BLOCK_SIZE) { 1 } else { 0 }
}
}
pub fn compute_block_hash(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
pub fn verify_block_hash(data: &[u8], expected: &[u8]) -> bool {
let actual = compute_block_hash(data);
actual == expected
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChecksumMode {
Lazy, // Only verify on scrub (default)
OnRead, // Verify every read
}
#[derive(Debug, Clone)]
pub struct ChecksumConfig {
pub mode: ChecksumMode,
pub cache_verified: bool,
}
impl Default for ChecksumConfig {
fn default() -> Self {
Self {
mode: ChecksumMode::Lazy,
cache_verified: true,
}
}
}
#[derive(Debug)]
pub struct ScrubResult {
pub path: PathBuf,
pub total_blocks: usize,
pub verified_blocks: usize,
pub corrupted_blocks: Vec<u64>,
pub repaired_blocks: Vec<u64>,
pub repair_failed: bool,
}
impl ScrubResult {
pub fn is_clean(&self) -> bool {
self.corrupted_blocks.is_empty()
}
pub fn repair_success_rate(&self) -> f64 {
if self.corrupted_blocks.is_empty() {
1.0
} else {
self.repaired_blocks.len() as f64 / self.corrupted_blocks.len() as f64
}
}
}
pub fn checksum_path_for_file(file_path: &PathBuf, root: &PathBuf) -> PathBuf {
let relative = file_path.strip_prefix(root)
.unwrap_or(file_path);
root.join(CHECKSUM_DIR)
.join(relative)
.with_extension(CHECKSUM_EXT)
}
pub fn ensure_checksum_dir(root: &PathBuf, backend: &dyn VfsBackend) -> Result<(), VfsError> {
let checksum_dir = root.join(CHECKSUM_DIR);
if !backend.exists(&checksum_dir) {
backend.create_dir(&checksum_dir, 0o755)?;
}
Ok(())
}
/// Scrub a single file to verify integrity
///
/// This reads the file and verifies each block checksum.
/// If repair=true and corrupted blocks are found, attempts to repair from RAID/Dedup.
pub fn scrub_file(
backend: &dyn VfsBackend,
file_path: &PathBuf,
root_path: &PathBuf,
repair: bool,
) -> Result<ScrubResult, VfsError> {
let checksum_path = checksum_path_for_file(file_path, root_path);
if !backend.exists(&checksum_path) {
return Ok(ScrubResult {
path: file_path.clone(),
total_blocks: 0,
verified_blocks: 0,
corrupted_blocks: vec![],
repaired_blocks: vec![],
repair_failed: false,
});
}
let checksum_file_data = {
let mut checksum_file = backend.open_file(&checksum_path, &super::open_flags::OpenFlags::new().read())?;
checksum_file.read_all()?
};
let checksum_data = VfsChecksumFile::from_bytes(&checksum_file_data)?;
let mut file_handle = backend.open_file(file_path, &super::open_flags::OpenFlags::new().read())?;
let stat = file_handle.stat()?;
let file_size = stat.size;
let block_count = checksum_data.block_count();
let mut verified_blocks = 0;
let mut corrupted_blocks: Vec<u64> = vec![];
let mut repaired_blocks: Vec<u64> = vec![];
for block_idx in 0..block_count {
let offset = (block_idx as u64) * BLOCK_SIZE as u64;
let block_size = if offset + BLOCK_SIZE as u64 <= file_size {
BLOCK_SIZE
} else {
(file_size - offset) as usize
};
let mut buffer = vec![0u8; block_size];
let bytes_read = file_handle.read_at(&mut buffer, offset)?;
if bytes_read != block_size {
corrupted_blocks.push(offset);
continue;
}
let expected_hash = checksum_data.get_checksum(offset);
if expected_hash.is_none() {
verified_blocks += 1;
continue;
}
let is_valid = verify_block_hash(&buffer, expected_hash.unwrap());
if is_valid {
verified_blocks += 1;
} else {
corrupted_blocks.push(offset);
if repair {
if repair_block(backend, file_path, offset, &buffer).is_ok() {
repaired_blocks.push(offset);
}
}
}
}
let corrupted_count = corrupted_blocks.len();
let repaired_count = repaired_blocks.len();
Ok(ScrubResult {
path: file_path.clone(),
total_blocks: block_count,
verified_blocks,
corrupted_blocks,
repaired_blocks,
repair_failed: repair && repaired_count < corrupted_count,
})
}
/// Scrub all files in a directory
///
/// Recursively walks the directory and scrubs all files with checksums.
pub fn scrub_all(
backend: &dyn VfsBackend,
root_path: &PathBuf,
repair: bool,
) -> Result<Vec<ScrubResult>, VfsError> {
let mut results = vec![];
let checksum_dir = root_path.join(CHECKSUM_DIR);
if !backend.exists(&checksum_dir) {
return Ok(results);
}
scrub_recursive(backend, root_path, root_path, repair, &mut results)?;
Ok(results)
}
fn scrub_recursive(
backend: &dyn VfsBackend,
current_path: &PathBuf,
root_path: &PathBuf,
repair: bool,
results: &mut Vec<ScrubResult>,
) -> Result<(), VfsError> {
let entries = backend.read_dir(current_path)?;
for entry in entries {
let entry_path = current_path.join(&entry.name);
if entry.stat.is_dir {
if entry.name != CHECKSUM_DIR {
scrub_recursive(backend, &entry_path, root_path, repair, results)?;
}
} else if !entry.name.ends_with(CHECKSUM_EXT) {
let result = scrub_file(backend, &entry_path, root_path, repair)?;
results.push(result);
}
}
Ok(())
}
/// Attempt to repair a corrupted block
///
/// Tries RAID repair first (if backend is RAID), then Dedup repair.
pub fn repair_block(
_backend: &dyn VfsBackend,
_file_path: &PathBuf,
_offset: u64,
_expected_checksum: &[u8],
) -> Result<Vec<u8>, VfsError> {
// Try Dedup repair first (check if block exists in dedup store)
// This requires the backend to have dedup integration
// For now, return error - RAID/Dedup repair requires specific backend types
Err(VfsError::Io("block repair requires RAID or Dedup backend (Phase 4/6)".to_string()))
}
/// Repair block from DedupStore
///
/// This is called when checksum detects corruption and dedup store is available.
pub fn repair_block_from_dedup(
dedup_store: &super::dedup::DedupStore,
checksum_hash: &[u8],
) -> Result<Vec<u8>, VfsError> {
dedup_store.repair_from_checksum(checksum_hash)
}
/// Create checksums for a file
///
/// This reads the file and computes checksums for all blocks.
pub fn create_checksums_for_file(
backend: &dyn VfsBackend,
file_path: &PathBuf,
root_path: &PathBuf,
) -> Result<(), VfsError> {
ensure_checksum_dir(root_path, backend)?;
let mut file_handle = backend.open_file(file_path, &super::open_flags::OpenFlags::new().read())?;
let stat = file_handle.stat()?;
let file_size = stat.size;
let mut checksum_data = VfsChecksumFile::new(file_size);
let block_count = checksum_data.block_count();
for block_idx in 0..block_count {
let offset = (block_idx as u64) * BLOCK_SIZE as u64;
let block_size = if offset + BLOCK_SIZE as u64 <= file_size {
BLOCK_SIZE
} else {
(file_size - offset) as usize
};
let mut buffer = vec![0u8; block_size];
let bytes_read = file_handle.read_at(&mut buffer, offset)?;
if bytes_read > 0 {
let hash = compute_block_hash(&buffer[..bytes_read]);
checksum_data.set_checksum(offset, hash);
}
}
let checksum_path = checksum_path_for_file(file_path, root_path);
let checksum_bytes = checksum_data.to_bytes()?;
let mut checksum_file = backend.open_file(
&checksum_path,
&super::open_flags::OpenFlags::new().write().create().truncate(),
)?;
checksum_file.write_all(&checksum_bytes)?;
checksum_file.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_block_hash() {
let data = b"test block data for hashing";
let hash = compute_block_hash(data);
assert_eq!(hash.len(), HASH_SIZE);
let hash2 = compute_block_hash(data);
assert_eq!(hash, hash2);
}
#[test]
fn test_verify_block_hash() {
let data = b"test block data";
let hash = compute_block_hash(data);
assert!(verify_block_hash(data, &hash));
let wrong_data = b"wrong block data";
assert!(!verify_block_hash(wrong_data, &hash));
}
#[test]
fn test_checksum_file_roundtrip() {
let mut checksum_file = VfsChecksumFile::new(8192);
checksum_file.set_checksum(0, compute_block_hash(b"block0"));
checksum_file.set_checksum(4096, compute_block_hash(b"block1"));
let bytes = checksum_file.to_bytes().unwrap();
let decoded = VfsChecksumFile::from_bytes(&bytes).unwrap();
assert_eq!(decoded.block_size, BLOCK_SIZE);
assert_eq!(decoded.blocks.len(), 2);
assert_eq!(decoded.file_size, 8192);
}
#[test]
fn test_checksum_file_get_set() {
let mut checksum_file = VfsChecksumFile::new(4096);
let hash = compute_block_hash(b"test");
checksum_file.set_checksum(0, hash.clone());
let retrieved = checksum_file.get_checksum(0);
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap(), hash.as_slice());
checksum_file.set_checksum(0, compute_block_hash(b"new"));
let updated = checksum_file.get_checksum(0).unwrap();
assert_ne!(updated, hash.as_slice());
}
#[test]
fn test_block_count_calculation() {
let checksum_file = VfsChecksumFile::new(4096);
assert_eq!(checksum_file.block_count(), 1);
let checksum_file = VfsChecksumFile::new(8192);
assert_eq!(checksum_file.block_count(), 2);
let checksum_file = VfsChecksumFile::new(4097);
assert_eq!(checksum_file.block_count(), 2);
let checksum_file = VfsChecksumFile::new(0);
assert_eq!(checksum_file.block_count(), 0);
}
#[test]
fn test_scrub_result_metrics() {
let result = ScrubResult {
path: PathBuf::from("/test"),
total_blocks: 10,
verified_blocks: 10,
corrupted_blocks: vec![],
repaired_blocks: vec![],
repair_failed: false,
};
assert!(result.is_clean());
assert_eq!(result.repair_success_rate(), 1.0);
let result2 = ScrubResult {
path: PathBuf::from("/test"),
total_blocks: 10,
verified_blocks: 8,
corrupted_blocks: vec![4096, 8192],
repaired_blocks: vec![4096],
repair_failed: false,
};
assert!(!result2.is_clean());
assert_eq!(result2.repair_success_rate(), 0.5);
}
}

View File

@@ -0,0 +1,259 @@
//! ChecksumFile Wrapper - Transparent checksum verification for VfsFile
//!
//! This wraps any VfsFile to provide:
//! - Automatic checksum calculation on write
//! - Optional verification on read (OnRead mode)
//! - Cache of verified blocks (Lazy mode)
//! - Scrub support for integrity checking
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::io::{Seek, SeekFrom};
use super::{VfsBackend, VfsFile, VfsStat, VfsError};
use super::checksum::{
VfsChecksumFile, ChecksumConfig, ChecksumMode,
BLOCK_SIZE, compute_block_hash, verify_block_hash,
checksum_path_for_file, ensure_checksum_dir,
};
use sha2::Digest;
pub struct ChecksumFile {
inner: Box<dyn VfsFile>,
file_path: PathBuf,
root_path: PathBuf,
backend: Box<dyn VfsBackend>,
config: ChecksumConfig,
checksum_data: Option<VfsChecksumFile>,
verified_cache: HashMap<u64, Vec<u8>>,
modified_blocks: HashSet<u64>,
current_offset: u64,
file_size: u64,
loaded: bool,
}
impl ChecksumFile {
pub fn new(
inner: Box<dyn VfsFile>,
file_path: PathBuf,
root_path: PathBuf,
backend: Box<dyn VfsBackend>,
config: ChecksumConfig,
) -> Self {
Self {
inner,
file_path,
root_path,
backend,
config,
checksum_data: None,
verified_cache: HashMap::new(),
modified_blocks: HashSet::new(),
current_offset: 0,
file_size: 0,
loaded: false,
}
}
fn load_checksum_file(&mut self) -> Result<(), VfsError> {
if self.loaded {
return Ok(());
}
let checksum_path = checksum_path_for_file(&self.file_path, &self.root_path);
if self.backend.exists(&checksum_path) {
let mut checksum_file = self.backend.open_file(&checksum_path, &super::open_flags::OpenFlags::new().read())?;
let data = checksum_file.read_all()?;
self.checksum_data = Some(VfsChecksumFile::from_bytes(&data)?);
} else {
let stat = self.inner.stat()?;
self.file_size = stat.size;
self.checksum_data = Some(VfsChecksumFile::new(self.file_size));
}
self.loaded = true;
Ok(())
}
fn save_checksum_file(&mut self) -> Result<(), VfsError> {
ensure_checksum_dir(&self.root_path, self.backend.as_ref())?;
if let Some(checksum_data) = &self.checksum_data {
let checksum_path = checksum_path_for_file(&self.file_path, &self.root_path);
let data = checksum_data.to_bytes()?;
let mut checksum_file = self.backend.open_file(
&checksum_path,
&super::open_flags::OpenFlags::new().write().create().truncate(),
)?;
checksum_file.write_all(&data)?;
checksum_file.flush()?;
}
Ok(())
}
fn get_block_offset(offset: u64) -> u64 {
(offset / BLOCK_SIZE as u64) * BLOCK_SIZE as u64
}
fn verify_block_at_offset(&mut self, offset: u64, data: &[u8]) -> Result<bool, VfsError> {
self.load_checksum_file()?;
let block_offset = Self::get_block_offset(offset);
if let Some(checksum_data) = &self.checksum_data {
if let Some(expected_hash) = checksum_data.get_checksum(block_offset) {
let is_valid = verify_block_hash(data, expected_hash);
if self.config.cache_verified && is_valid {
self.verified_cache.insert(block_offset, expected_hash.to_vec());
}
return Ok(is_valid);
}
}
Ok(true)
}
fn update_checksum_for_block(&mut self, offset: u64, data: &[u8]) -> Result<(), VfsError> {
self.load_checksum_file()?;
let block_offset = Self::get_block_offset(offset);
let hash = compute_block_hash(data);
if let Some(checksum_data) = &mut self.checksum_data {
checksum_data.set_checksum(block_offset, hash);
}
self.modified_blocks.insert(block_offset);
Ok(())
}
pub fn get_checksum_data(&self) -> Option<&VfsChecksumFile> {
self.checksum_data.as_ref()
}
pub fn get_modified_blocks(&self) -> &HashSet<u64> {
&self.modified_blocks
}
pub fn get_verified_cache(&self) -> &HashMap<u64, Vec<u8>> {
&self.verified_cache
}
}
impl VfsFile for ChecksumFile {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, VfsError> {
let bytes_read = self.inner.read(buf)?;
if bytes_read > 0 && self.config.mode == ChecksumMode::OnRead {
self.verify_block_at_offset(self.current_offset, &buf[..bytes_read])?;
}
self.current_offset += bytes_read as u64;
Ok(bytes_read)
}
fn write(&mut self, buf: &[u8]) -> Result<usize, VfsError> {
let bytes_written = self.inner.write(buf)?;
if bytes_written > 0 {
self.update_checksum_for_block(self.current_offset, buf)?;
self.current_offset += bytes_written as u64;
if self.current_offset > self.file_size {
self.file_size = self.current_offset;
if let Some(checksum_data) = &mut self.checksum_data {
checksum_data.file_size = self.file_size;
}
}
}
Ok(bytes_written)
}
fn seek(&mut self, pos: SeekFrom) -> Result<u64, VfsError> {
self.current_offset = self.inner.seek(pos)?;
Ok(self.current_offset)
}
fn flush(&mut self) -> Result<(), VfsError> {
self.inner.flush()?;
if !self.modified_blocks.is_empty() {
self.save_checksum_file()?;
self.modified_blocks.clear();
}
Ok(())
}
fn stat(&mut self) -> Result<VfsStat, VfsError> {
let stat = self.inner.stat()?;
Ok(stat)
}
fn set_len(&mut self, size: u64) -> Result<(), VfsError> {
self.inner.set_len(size)?;
self.file_size = size;
if let Some(checksum_data) = &mut self.checksum_data {
checksum_data.file_size = size;
}
Ok(())
}
fn read_at(&mut self, buf: &mut [u8], offset: u64) -> Result<usize, VfsError> {
let bytes_read = self.inner.read_at(buf, offset)?;
if bytes_read > 0 && self.config.mode == ChecksumMode::OnRead {
self.verify_block_at_offset(offset, &buf[..bytes_read])?;
}
Ok(bytes_read)
}
fn write_at(&mut self, buf: &[u8], offset: u64) -> Result<usize, VfsError> {
let bytes_written = self.inner.write_at(buf, offset)?;
if bytes_written > 0 {
self.update_checksum_for_block(offset, buf)?;
let new_size = offset + bytes_written as u64;
if new_size > self.file_size {
self.file_size = new_size;
if let Some(checksum_data) = &mut self.checksum_data {
checksum_data.file_size = self.file_size;
}
}
}
Ok(bytes_written)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_block_offset_calculation() {
assert_eq!(ChecksumFile::get_block_offset(0), 0);
assert_eq!(ChecksumFile::get_block_offset(4095), 0);
assert_eq!(ChecksumFile::get_block_offset(4096), 4096);
assert_eq!(ChecksumFile::get_block_offset(8191), 4096);
assert_eq!(ChecksumFile::get_block_offset(8192), 8192);
}
#[test]
fn test_checksum_config_default() {
let config = ChecksumConfig::default();
assert_eq!(config.mode, ChecksumMode::Lazy);
assert!(config.cache_verified);
}
}

View File

@@ -27,7 +27,7 @@ impl Compressor {
.map_err(|e| VfsError::Io(format!("ZSTD compression failed: {}", e)))
}
VfsCompression::Lz4 => {
Err(VfsError::Unsupported("LZ4 compression not yet implemented".to_string()))
Ok(lz4_flex::compress_prepend_size(data))
}
}
}
@@ -40,7 +40,8 @@ impl Compressor {
.map_err(|e| VfsError::Io(format!("ZSTD decompression failed: {}", e)))
}
VfsCompression::Lz4 => {
Err(VfsError::Unsupported("LZ4 decompression not yet implemented".to_string()))
lz4_flex::decompress_size_prepended(data)
.map_err(|e| VfsError::Io(format!("LZ4 decompression failed: {}", e)))
}
}
}

View File

@@ -181,6 +181,31 @@ impl DedupStore {
stats.total_blocks = stats.total_refs;
Ok(stats)
}
/// Retrieve block by checksum hash (for scrub repair)
///
/// Converts the checksum hash (Vec<u8>) to hex format and retrieves from dedup store.
pub fn get_block_by_checksum(&self, checksum_hash: &[u8]) -> Result<Vec<u8>, VfsError> {
let hash_hex = hex::encode(checksum_hash);
self.get_block(&hash_hex)
}
/// Check if a block exists by checksum hash
pub fn has_block_by_checksum(&self, checksum_hash: &[u8]) -> bool {
let hash_hex = hex::encode(checksum_hash);
self.store_path.join(&hash_hex).exists()
}
/// Repair a corrupted block from dedup store
///
/// If the dedup store contains a block with the same checksum, retrieve it.
pub fn repair_from_checksum(&self, checksum_hash: &[u8]) -> Result<Vec<u8>, VfsError> {
if self.has_block_by_checksum(checksum_hash) {
self.get_block_by_checksum(checksum_hash)
} else {
Err(VfsError::NotFound("Block not found in dedup store".to_string()))
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]

View File

@@ -0,0 +1,343 @@
//! Encrypted VFS Backend - Transparent at-rest encryption using AES-256-GCM
//!
//! This module provides transparent file encryption at the VFS layer.
//! Files are encrypted before being written to disk and decrypted on read.
//!
//! Format:
//! - Header (32 bytes): magic(4) + version(4) + nonce(12) + original_size(8) + reserved(4)
//! - Body: AES-256-GCM encrypted data
//! - Tag (16 bytes): GCM authentication tag
use std::path::PathBuf;
use std::io::{Seek, SeekFrom};
use aes_gcm::{
Aes256Gcm, Nonce, aead::{Aead, KeyInit},
};
use sha2::{Sha256, Digest};
use super::{VfsBackend, VfsFile, VfsStat, VfsError};
use super::local_fs::LocalFs;
const ENCRYPTED_MAGIC: &[u8] = b"MBE1"; // MarkBase Encrypted v1
const ENCRYPTED_VERSION: u32 = 1;
const HEADER_SIZE: usize = 32;
const TAG_SIZE: usize = 16;
const NONCE_SIZE: usize = 12;
const KEY_SIZE: usize = 32;
#[derive(Debug, Clone)]
pub struct EncryptedVfsConfig {
pub master_key: Vec<u8>, // 32 bytes for AES-256
pub encrypt_filenames: bool, // Future feature
}
impl EncryptedVfsConfig {
pub fn new(master_key: [u8; 32]) -> Self {
Self {
master_key: master_key.to_vec(),
encrypt_filenames: false,
}
}
pub fn from_password(password: &str) -> Self {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
let key = hasher.finalize();
Self {
master_key: key.to_vec(),
encrypt_filenames: false,
}
}
}
pub struct EncryptedVfs {
inner: Box<dyn VfsBackend>,
config: EncryptedVfsConfig,
}
impl EncryptedVfs {
pub fn new(inner: Box<dyn VfsBackend>, config: EncryptedVfsConfig) -> Self {
Self { inner, config }
}
pub fn wrap_local_fs(_root: PathBuf, config: EncryptedVfsConfig) -> Self {
Self::new(Box::new(LocalFs::new()), config)
}
fn derive_key(&self, path: &PathBuf) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(&self.config.master_key);
hasher.update(path.to_string_lossy().as_bytes());
let derived = hasher.finalize();
derived[..KEY_SIZE].to_vec()
}
pub fn is_encrypted_file(data: &[u8]) -> bool {
data.len() >= HEADER_SIZE + TAG_SIZE && &data[..4] == ENCRYPTED_MAGIC
}
fn encrypt_data(&self, path: &PathBuf, data: &[u8]) -> Result<Vec<u8>, VfsError> {
let key_bytes = self.derive_key(path);
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.map_err(|e| VfsError::Io(format!("cipher init failed: {}", e)))?;
let nonce_bytes: [u8; NONCE_SIZE] = rand_key(12).try_into().map_err(|_| VfsError::Io("nonce generation failed".to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher.encrypt(nonce, data)
.map_err(|e| VfsError::Io(format!("encryption failed: {}", e)))?;
let mut result = Vec::with_capacity(HEADER_SIZE + ciphertext.len() + TAG_SIZE);
result.extend_from_slice(ENCRYPTED_MAGIC);
result.extend_from_slice(&ENCRYPTED_VERSION.to_le_bytes());
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&(data.len() as u64).to_le_bytes());
result.extend_from_slice(&[0u8; 4]);
result.extend_from_slice(&ciphertext);
Ok(result)
}
fn decrypt_data(&self, path: &PathBuf, data: &[u8]) -> Result<Vec<u8>, VfsError> {
if !Self::is_encrypted_file(data) {
return Err(VfsError::Io("not an encrypted file".to_string()));
}
let key_bytes = self.derive_key(path);
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.map_err(|e| VfsError::Io(format!("cipher init failed: {}", e)))?;
let nonce_bytes: [u8; NONCE_SIZE] = data[8..20].try_into().map_err(|_| VfsError::Io("invalid nonce".to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let original_size = u64::from_le_bytes(data[20..28].try_into().map_err(|_| VfsError::Io("invalid size".to_string()))?) as usize;
let ciphertext = &data[HEADER_SIZE..];
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|e| VfsError::Io(format!("decryption failed: {}", e)))?;
if plaintext.len() != original_size {
return Err(VfsError::Io(format!("size mismatch: expected {}, got {}", original_size, plaintext.len())));
}
Ok(plaintext)
}
}
fn rand_key(len: usize) -> Vec<u8> {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let mut hasher = Sha256::new();
hasher.update(now.to_le_bytes());
hasher.update([0u8; 32]);
let hash = hasher.finalize();
hash[..len].to_vec()
}
pub struct EncryptedFile {
inner: Box<dyn VfsFile>,
path: PathBuf,
config: EncryptedVfsConfig,
decrypted_data: Option<Vec<u8>>,
modified: bool,
position: u64,
}
impl EncryptedFile {
fn decrypt_on_open(&mut self) -> Result<(), VfsError> {
let encrypted = self.inner.read_all()?;
if EncryptedVfs::is_encrypted_file(&encrypted) {
let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), self.config.clone());
self.decrypted_data = Some(vfs.decrypt_data(&self.path, &encrypted)?);
} else {
self.decrypted_data = Some(encrypted);
}
Ok(())
}
fn encrypt_on_close(&mut self) -> Result<(), VfsError> {
if !self.modified {
return Ok(());
}
let data = self.decrypted_data.as_ref().ok_or_else(|| VfsError::Io("no data to encrypt".to_string()))?;
let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), self.config.clone());
let encrypted = vfs.encrypt_data(&self.path, data)?;
self.inner.seek(SeekFrom::Start(0))?;
self.inner.write_all(&encrypted)?;
Ok(())
}
}
impl VfsFile for EncryptedFile {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, VfsError> {
if self.decrypted_data.is_none() {
self.decrypt_on_open()?;
}
let data = self.decrypted_data.as_ref().ok_or_else(|| VfsError::Io("no decrypted data".to_string()))?;
let start = self.position as usize;
let end = std::cmp::min(start + buf.len(), data.len());
if start >= data.len() {
return Ok(0);
}
buf[..(end - start)].copy_from_slice(&data[start..end]);
self.position += (end - start) as u64;
Ok(end - start)
}
fn write(&mut self, buf: &[u8]) -> Result<usize, VfsError> {
if self.decrypted_data.is_none() {
self.decrypted_data = Some(Vec::new());
}
let data = self.decrypted_data.as_mut().ok_or_else(|| VfsError::Io("no decrypted data".to_string()))?;
let start = self.position as usize;
if start + buf.len() > data.len() {
data.resize(start + buf.len(), 0);
}
data[start..start + buf.len()].copy_from_slice(buf);
self.position += buf.len() as u64;
self.modified = true;
Ok(buf.len())
}
fn seek(&mut self, pos: SeekFrom) -> Result<u64, VfsError> {
match pos {
SeekFrom::Start(offset) => {
self.position = offset;
}
SeekFrom::Current(offset) => {
self.position = (self.position as i64 + offset) as u64;
}
SeekFrom::End(offset) => {
let len = self.decrypted_data.as_ref().map(|d| d.len() as i64).unwrap_or(0);
self.position = (len + offset) as u64;
}
}
Ok(self.position)
}
fn flush(&mut self) -> Result<(), VfsError> {
self.encrypt_on_close()?;
self.inner.flush()?;
Ok(())
}
fn stat(&mut self) -> Result<VfsStat, VfsError> {
let stat = self.inner.stat()?;
Ok(VfsStat {
size: self.decrypted_data.as_ref().map(|d| d.len() as u64).unwrap_or(stat.size),
mode: stat.mode,
uid: stat.uid,
gid: stat.gid,
atime: stat.atime,
mtime: stat.mtime,
is_dir: false,
is_symlink: false,
})
}
fn set_len(&mut self, size: u64) -> Result<(), VfsError> {
if self.decrypted_data.is_none() {
self.decrypted_data = Some(Vec::new());
}
let data = self.decrypted_data.as_mut().ok_or_else(|| VfsError::Io("no decrypted data".to_string()))?;
data.resize(size as usize, 0);
self.modified = true;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let config = EncryptedVfsConfig::from_password("test_password");
let path = PathBuf::from("/test/file.txt");
let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), config.clone());
let original = b"Hello, World! This is a test message.";
let encrypted = vfs.encrypt_data(&path, original).unwrap();
assert!(encrypted.len() > original.len());
assert!(EncryptedVfs::is_encrypted_file(&encrypted));
let decrypted = vfs.decrypt_data(&path, &encrypted).unwrap();
assert_eq!(decrypted, original);
}
#[test]
fn test_different_keys_produce_different_ciphertext() {
let config1 = EncryptedVfsConfig::from_password("password1");
let config2 = EncryptedVfsConfig::from_password("password2");
let path = PathBuf::from("/test/file.txt");
let vfs1 = EncryptedVfs::new(Box::new(LocalFs::new()), config1);
let vfs2 = EncryptedVfs::new(Box::new(LocalFs::new()), config2);
let original = b"Same content";
let enc1 = vfs1.encrypt_data(&path, original).unwrap();
let enc2 = vfs2.encrypt_data(&path, original).unwrap();
assert_ne!(enc1, enc2);
}
#[test]
fn test_key_derivation() {
let config = EncryptedVfsConfig::from_password("test_password");
let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), config);
let key1 = vfs.derive_key(&PathBuf::from("/file1.txt"));
let key2 = vfs.derive_key(&PathBuf::from("/file2.txt"));
assert_ne!(key1, key2);
}
#[test]
fn test_header_format() {
let config = EncryptedVfsConfig::from_password("test");
let path = PathBuf::from("/test.txt");
let vfs = EncryptedVfs::new(Box::new(LocalFs::new()), config);
let data = b"test";
let encrypted = vfs.encrypt_data(&path, data).unwrap();
assert_eq!(&encrypted[..4], ENCRYPTED_MAGIC);
assert_eq!(u32::from_le_bytes(encrypted[4..8].try_into().unwrap()), ENCRYPTED_VERSION);
assert_eq!(encrypted.len(), HEADER_SIZE + data.len() + TAG_SIZE);
}
#[test]
fn test_config_from_password() {
let config = EncryptedVfsConfig::from_password("my_secret_password");
assert_eq!(config.master_key.len(), KEY_SIZE);
let config2 = EncryptedVfsConfig::from_password("my_secret_password");
assert_eq!(config.master_key, config2.master_key);
let config3 = EncryptedVfsConfig::from_password("different");
assert_ne!(config.master_key, config3.master_key);
}
}

View File

@@ -596,7 +596,7 @@ impl VfsBackend for LocalFs {
fn get_xattr(&self, path: &Path, name: &str) -> Result<Vec<u8>, VfsError> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let _meta = path.metadata().map_err(|e| util::map_io_error(path, e))?;
xattr::get(path, name)
.map_err(|e| VfsError::Io(e.to_string()))?

View File

@@ -1,11 +1,19 @@
pub mod backup_manifest;
pub mod backup_scheduler;
pub mod cache;
pub mod checksum;
pub mod checksum_file;
pub mod compression;
pub mod dedup;
pub mod encrypted_fs;
pub mod local_fs;
pub mod open_flags;
pub mod raid;
pub mod scrub_scheduler;
pub mod send_receive;
pub mod s3_fs;
pub mod smb_fs;
pub mod storage_stats;
#[cfg(feature = "smb-server")]
pub mod smb_server_backend;
pub mod util;
@@ -16,6 +24,8 @@ pub mod async_fs;
pub mod async_s3_fs;
#[cfg(feature = "async-vfs")]
pub mod async_smb_fs;
#[cfg(feature = "nfs")]
pub mod nfs_server;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
@@ -140,6 +150,15 @@ pub trait VfsFile: Send {
}
Ok(())
}
/// Read all bytes (convenience, seeks to end first to get size)
fn read_all(&mut self) -> Result<Vec<u8>, VfsError> {
let size = self.seek(std::io::SeekFrom::End(0))?;
self.seek(std::io::SeekFrom::Start(0))?;
let mut buf = vec![0u8; size as usize];
self.read_exact(&mut buf)?;
Ok(buf)
}
}
/// VFS 后端 trait所有文件系统操作

View File

@@ -0,0 +1,63 @@
use crate::vfs::{VfsBackend, VfsError};
use std::path::PathBuf;
use std::sync::Arc;
pub struct NfsVfsServer {
vfs: Arc<dyn VfsBackend>,
root: PathBuf,
port: u16,
}
impl NfsVfsServer {
pub fn new(vfs: Arc<dyn VfsBackend>, root: PathBuf) -> Self {
Self {
vfs,
root,
port: 2049,
}
}
pub fn with_port(self, port: u16) -> Self {
Self { port, ..self }
}
pub async fn start(&self, port: u16) -> Result<(), VfsError> {
#[cfg(feature = "nfs")]
{
println!("NFS server starting on port {}", port);
println!("Export directory: {}", self.root.display());
// TODO: Implement actual NFS server using nfsserve crate
// Current implementation is a placeholder
Err(VfsError::Unsupported("NFS server implementation pending (requires nfsserve crate API study)".to_string()))
}
#[cfg(not(feature = "nfs"))]
{
Err(VfsError::Unsupported("NFS server requires 'nfs' feature".to_string()))
}
}
}
pub struct NfsConfig {
pub port: u16,
pub root: PathBuf,
pub vfs: Arc<dyn VfsBackend>,
}
impl Default for NfsConfig {
fn default() -> Self {
Self {
port: 2049,
root: PathBuf::from("/"),
vfs: Arc::new(crate::vfs::local_fs::LocalFs::new()),
}
}
}
impl NfsConfig {
pub fn build(&self) -> NfsVfsServer {
NfsVfsServer::new(self.vfs.clone(), self.root.clone()).with_port(self.port)
}
}

View File

@@ -47,6 +47,14 @@ impl VfsRaidBackend {
}
}
pub fn level(&self) -> VfsRaidLevel {
self.config.level
}
pub fn backends(&self) -> &[Box<dyn VfsBackend>] {
&self.backends
}
fn calculate_parity_p(data: &[u8]) -> Vec<u8> {
data.iter().fold(vec![0u8; data.len()], |mut p, byte| {
for i in 0..p.len() {
@@ -109,17 +117,190 @@ impl VfsRaidBackend {
(offset / self.stripe_size as u64) as usize % self.backends.len()
}
fn rebuild_disk(&self, _failed_disk_index: usize) -> Result<(), VfsError> {
fn rebuild_disk(&self, failed_disk_index: usize) -> Result<(), VfsError> {
if self.config.level == VfsRaidLevel::Single {
return Err(VfsError::Io("Cannot rebuild single disk RAID".to_string()));
}
for backend in &self.backends {
backend.create_dir_all(&PathBuf::from("/"), 0o755)?;
if failed_disk_index >= self.backends.len() {
return Err(VfsError::Io(format!("Invalid disk index {}", failed_disk_index)));
}
let source_index = if self.backends.len() > 1 {
// Use backends[0] as source if failed_disk_index != 0, else use backends[1]
if failed_disk_index != 0 { 0 } else { 1 }
} else {
return Err(VfsError::Io("Not enough disks for rebuild".to_string()));
};
let target_backend = &self.backends[failed_disk_index];
let source_backend = &self.backends[source_index];
target_backend.create_dir_all(&PathBuf::from("/"), 0o755)?;
self.rebuild_recursive(source_backend, target_backend, &PathBuf::from("/"))?;
Ok(())
}
fn rebuild_recursive(
&self,
source: &Box<dyn VfsBackend>,
target: &Box<dyn VfsBackend>,
path: &Path,
) -> Result<(), VfsError> {
let entries = source.read_dir(path)?;
for entry in &entries {
let entry_path = path.join(&entry.name);
if entry.stat.is_dir {
target.create_dir_all(&entry_path, entry.stat.mode)?;
self.rebuild_recursive(source, target, &entry_path)?;
} else {
let mut src_file = source.open_file(&entry_path, &super::open_flags::OpenFlags::new().read())?;
let data = src_file.read_all()?;
let mut dst_file = target.open_file(
&entry_path,
&super::open_flags::OpenFlags::new().write().create().truncate(),
)?;
dst_file.write_all(&data)?;
if let Ok(stat) = source.stat(&entry_path) {
target.set_stat(&entry_path, &stat)?;
}
}
}
Ok(())
}
/// Repair a corrupted block from parity
///
/// This reads the block from surviving disks and reconstructs using parity.
/// Works for RAID-Z1/2/3 (requires parity disks).
pub fn repair_block_from_parity(
&self,
path: &Path,
offset: u64,
corrupted_disk_index: usize,
) -> Result<Vec<u8>, VfsError> {
if self.config.level == VfsRaidLevel::Single {
return Err(VfsError::Io("Cannot repair from single disk RAID".to_string()));
}
if corrupted_disk_index >= self.backends.len() {
return Err(VfsError::Io(format!("Invalid disk index {}", corrupted_disk_index)));
}
let block_size = self.stripe_size;
let mut data_blocks: Vec<Option<Vec<u8>>> = vec![None; self.backends.len()];
let mut parity_blocks: Vec<Vec<u8>> = vec![];
for (i, backend) in self.backends.iter().enumerate() {
if i == corrupted_disk_index {
continue;
}
let mut file = backend.open_file(path, &super::open_flags::OpenFlags::new().read())?;
let mut buffer = vec![0u8; block_size];
let bytes_read = file.read_at(&mut buffer, offset)?;
if bytes_read > 0 {
if i < self.data_disks() {
data_blocks[i] = Some(buffer[..bytes_read].to_vec());
} else {
parity_blocks.push(buffer[..bytes_read].to_vec());
}
}
}
match self.config.level {
VfsRaidLevel::RaidZ1 => {
if parity_blocks.is_empty() {
return Err(VfsError::Io("Not enough parity for RaidZ1 repair".to_string()));
}
let reconstructed = Self::reconstruct_from_p(
&data_blocks,
&parity_blocks[0],
corrupted_disk_index,
self.data_disks(),
);
Ok(reconstructed)
}
VfsRaidLevel::RaidZ2 => {
if parity_blocks.len() < 2 {
return Err(VfsError::Io("Not enough parity for RaidZ2 repair".to_string()));
}
let reconstructed = Self::reconstruct_from_pq(
&data_blocks,
&parity_blocks[0],
&parity_blocks[1],
corrupted_disk_index,
self.data_disks(),
);
Ok(reconstructed)
}
VfsRaidLevel::RaidZ3 => {
if parity_blocks.len() < 3 {
return Err(VfsError::Io("Not enough parity for RaidZ3 repair".to_string()));
}
let reconstructed = Self::reconstruct_from_pqr(
&data_blocks,
&parity_blocks[0],
&parity_blocks[1],
&parity_blocks[2],
corrupted_disk_index,
self.data_disks(),
);
Ok(reconstructed)
}
_ => Err(VfsError::Io("RAID level does not support block repair".to_string())),
}
}
fn reconstruct_from_p(
data_blocks: &[Option<Vec<u8>>],
p_block: &[u8],
missing_index: usize,
data_disk_count: usize,
) -> Vec<u8> {
let size = p_block.len();
let mut reconstructed = vec![0u8; size];
for i in 0..data_disk_count {
if i != missing_index {
if let Some(data) = &data_blocks[i] {
for j in 0..size {
reconstructed[j] ^= data[j];
}
}
}
}
for j in 0..size {
reconstructed[j] ^= p_block[j];
}
reconstructed
}
fn reconstruct_from_pq(
data_blocks: &[Option<Vec<u8>>],
p_block: &[u8],
_q_block: &[u8],
missing_index: usize,
data_disk_count: usize,
) -> Vec<u8> {
Self::reconstruct_from_p(data_blocks, p_block, missing_index, data_disk_count)
}
fn reconstruct_from_pqr(
data_blocks: &[Option<Vec<u8>>],
p_block: &[u8],
_q_block: &[u8],
_r_block: &[u8],
missing_index: usize,
data_disk_count: usize,
) -> Vec<u8> {
Self::reconstruct_from_p(data_blocks, p_block, missing_index, data_disk_count)
}
}
impl VfsBackend for VfsRaidBackend {

View File

@@ -0,0 +1,268 @@
//! Background Scrub Scheduler
//!
//! Automatically runs scrub operations at regular intervals.
//! Similar to ZFS `zpool scrub` and Btrfs periodic scrub.
use std::sync::Arc;
use std::path::PathBuf;
use super::{VfsBackend, VfsError};
use super::checksum::{scrub_all, ScrubResult};
pub struct ScrubSchedulerConfig {
pub interval_secs: u64, // Default: 3600 (1 hour)
pub scrub_on_startup: bool, // Default: true
pub repair_enabled: bool, // Default: true
pub max_files_per_run: usize, // Default: 100 (limit per run)
}
impl Default for ScrubSchedulerConfig {
fn default() -> Self {
Self {
interval_secs: 3600,
scrub_on_startup: true,
repair_enabled: true,
max_files_per_run: 100,
}
}
}
pub struct ScrubScheduler {
backend: Arc<dyn VfsBackend>,
root_path: PathBuf,
config: ScrubSchedulerConfig,
running: bool,
last_scrub_time: Option<u64>,
scrub_count: usize,
}
impl ScrubScheduler {
pub fn new(
backend: Arc<dyn VfsBackend>,
root_path: PathBuf,
config: ScrubSchedulerConfig,
) -> Self {
Self {
backend,
root_path,
config,
running: false,
last_scrub_time: None,
scrub_count: 0,
}
}
pub fn with_defaults(
backend: Arc<dyn VfsBackend>,
root_path: PathBuf,
) -> Self {
Self::new(backend, root_path, ScrubSchedulerConfig::default())
}
pub fn start(&mut self) {
self.running = true;
}
pub fn stop(&mut self) {
self.running = false;
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn get_last_scrub_time(&self) -> Option<u64> {
self.last_scrub_time
}
pub fn get_scrub_count(&self) -> usize {
self.scrub_count
}
pub fn should_run_now(&self) -> bool {
self.running && self.should_run_based_on_interval()
}
fn should_run_based_on_interval(&self) -> bool {
if self.last_scrub_time.is_none() {
return self.config.scrub_on_startup;
}
let now = current_time_secs();
let last = self.last_scrub_time.unwrap();
now - last >= self.config.interval_secs
}
pub fn run_once(&mut self) -> Result<Vec<ScrubResult>, VfsError> {
if !self.running {
return Ok(vec![]);
}
let results = scrub_all(
self.backend.as_ref(),
&self.root_path,
self.config.repair_enabled,
)?;
self.last_scrub_time = Some(current_time_secs());
self.scrub_count += 1;
Ok(results)
}
pub fn get_stats(&self) -> ScrubStats {
ScrubStats {
running: self.running,
scrub_count: self.scrub_count,
last_scrub_time: self.last_scrub_time,
interval_secs: self.config.interval_secs,
next_scrub_time: self.calculate_next_scrub_time(),
}
}
fn calculate_next_scrub_time(&self) -> Option<u64> {
if !self.running {
return None;
}
let last = self.last_scrub_time.unwrap_or(current_time_secs());
Some(last + self.config.interval_secs)
}
}
fn current_time_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug)]
pub struct ScrubStats {
pub running: bool,
pub scrub_count: usize,
pub last_scrub_time: Option<u64>,
pub interval_secs: u64,
pub next_scrub_time: Option<u64>,
}
impl ScrubStats {
pub fn next_scrub_in_secs(&self) -> Option<u64> {
if !self.running {
return None;
}
let now = current_time_secs();
let next = self.next_scrub_time?;
if next > now {
Some(next - now)
} else {
Some(0)
}
}
pub fn format_last_scrub(&self) -> String {
match self.last_scrub_time {
None => "Never".to_string(),
Some(t) => format_timestamp(t),
}
}
pub fn format_next_scrub(&self) -> String {
match self.next_scrub_time {
None => "Not scheduled".to_string(),
Some(t) => format_timestamp(t),
}
}
}
fn format_timestamp(secs: u64) -> String {
use chrono::{Utc, TimeZone};
Utc.timestamp_opt(secs as i64, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| format!("{} seconds since epoch", secs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ScrubSchedulerConfig::default();
assert_eq!(config.interval_secs, 3600);
assert!(config.scrub_on_startup);
assert!(config.repair_enabled);
assert_eq!(config.max_files_per_run, 100);
}
#[test]
fn test_scheduler_start_stop() {
let backend: Arc<dyn VfsBackend> = Arc::new(super::super::local_fs::LocalFs::new());
let mut scheduler = ScrubScheduler::with_defaults(backend, PathBuf::from("/tmp"));
assert!(!scheduler.is_running());
scheduler.start();
assert!(scheduler.is_running());
scheduler.stop();
assert!(!scheduler.is_running());
}
#[test]
fn test_scrub_stats() {
let now = current_time_secs();
let stats = ScrubStats {
running: true,
scrub_count: 5,
last_scrub_time: Some(now - 3600),
interval_secs: 3600,
next_scrub_time: Some(now), // Next scrub is now
};
assert!(stats.running);
assert_eq!(stats.scrub_count, 5);
// When next_scrub_time is now, next_scrub_in_secs should be 0
let next_in = stats.next_scrub_in_secs();
assert!(next_in.unwrap_or(999) <= 10); // Allow 10 seconds tolerance
}
#[test]
fn test_format_timestamp() {
let formatted = format_timestamp(1609459200); // 2021-01-01 00:00:00 UTC
assert!(formatted.contains("2021"));
}
#[test]
fn test_should_run_on_startup() {
let backend: Arc<dyn VfsBackend> = Arc::new(super::super::local_fs::LocalFs::new());
let mut scheduler = ScrubScheduler::with_defaults(backend, PathBuf::from("/tmp"));
scheduler.start();
assert!(scheduler.should_run_now()); // scrub_on_startup = true
scheduler.last_scrub_time = Some(current_time_secs());
assert!(!scheduler.should_run_now()); // Just ran, interval not elapsed
}
#[test]
fn test_should_run_after_interval() {
let backend: Arc<dyn VfsBackend> = Arc::new(super::super::local_fs::LocalFs::new());
let config = ScrubSchedulerConfig {
interval_secs: 3600,
scrub_on_startup: false,
repair_enabled: true,
max_files_per_run: 100,
};
let mut scheduler = ScrubScheduler::new(backend, PathBuf::from("/tmp"), config);
scheduler.start();
assert!(!scheduler.should_run_now()); // scrub_on_startup = false
scheduler.last_scrub_time = Some(current_time_secs() - 3601);
assert!(scheduler.should_run_now()); // Interval elapsed
}
}

View File

@@ -0,0 +1,443 @@
//! Send/Receive API - Snapshot replication
//!
//! Reference: ZFS send/receive, Proxmox Backup Server
//! Supports incremental backups and multiple formats
use std::path::PathBuf;
use std::collections::HashSet;
use super::{VfsBackend, VfsError, VfsCompression};
use super::backup_manifest::{BackupManifest, BackupStream, SendFormat, MANIFEST_FILE};
use super::checksum::{VfsChecksumFile, scrub_file};
pub struct SendOptions {
pub format: SendFormat,
pub incremental_from: Option<String>,
pub compress: VfsCompression,
pub encrypt: bool,
pub include_checksums: bool,
}
impl Default for SendOptions {
fn default() -> Self {
Self {
format: SendFormat::CustomJson,
incremental_from: None,
compress: VfsCompression::Zstd,
encrypt: false,
include_checksums: true,
}
}
}
pub struct ReceiveOptions {
pub format: SendFormat,
pub verify_checksums: bool,
pub target_name: Option<String>,
}
impl Default for ReceiveOptions {
fn default() -> Self {
Self {
format: SendFormat::CustomJson,
verify_checksums: true,
target_name: None,
}
}
}
pub fn send_snapshot(
backend: &dyn VfsBackend,
snapshot_name: &str,
root: &PathBuf,
options: SendOptions,
) -> Result<BackupStream, VfsError> {
let snapshot_dir = root.join(".snapshots").join(snapshot_name);
if !backend.exists(&snapshot_dir) {
return Err(VfsError::NotFound(format!("Snapshot {} not found", snapshot_name)));
}
let mut manifest = BackupManifest::new(snapshot_name.to_string(), root.clone());
let entries = backend.read_dir(&snapshot_dir)?;
for entry in entries {
if entry.name == MANIFEST_FILE || entry.name == ".meta" {
continue;
}
let file_path = snapshot_dir.join(&entry.name);
if entry.stat.is_dir {
collect_directory_files(backend, &file_path, &snapshot_dir, &mut manifest, &options)?;
} else {
add_file_to_manifest(backend, &file_path, &snapshot_dir, &mut manifest, &options)?;
}
}
manifest.calculate_ratio();
let payload = if options.incremental_from.is_some() {
let from_snap = options.incremental_from.unwrap();
send_incremental_payload(backend, &from_snap, snapshot_name, root)?
} else {
collect_snapshot_data(backend, &snapshot_dir)?
};
Ok(BackupStream::new(options.format, manifest, payload))
}
pub fn receive_snapshot(
backend: &dyn VfsBackend,
stream: &BackupStream,
root: &PathBuf,
options: ReceiveOptions,
) -> Result<String, VfsError> {
let snapshot_name = options.target_name.clone()
.unwrap_or_else(|| stream.manifest.snapshot_name.clone());
let snapshot_dir = root.join(".snapshots").join(&snapshot_name);
if backend.exists(&snapshot_dir) {
return Err(VfsError::Io(format!("Snapshot {} already exists", snapshot_name)));
}
backend.create_dir(&snapshot_dir, 0o755)?;
restore_snapshot_data(backend, &stream.data, &snapshot_dir)?;
stream.manifest.save(&snapshot_dir).map_err(VfsError::Io)?;
if options.verify_checksums {
verify_snapshot_checksums(backend, &snapshot_dir, root)?;
}
Ok(snapshot_name)
}
pub fn send_incremental(
backend: &dyn VfsBackend,
from_snapshot: &str,
to_snapshot: &str,
root: &PathBuf,
options: SendOptions,
) -> Result<BackupStream, VfsError> {
let mut opts = options;
opts.incremental_from = Some(from_snapshot.to_string());
send_snapshot(backend, to_snapshot, root, opts)
}
fn collect_directory_files(
backend: &dyn VfsBackend,
dir: &PathBuf,
snapshot_dir: &PathBuf,
manifest: &mut BackupManifest,
options: &SendOptions,
) -> Result<(), VfsError> {
let entries = backend.read_dir(dir)?;
for entry in entries {
let path = dir.join(&entry.name);
if entry.stat.is_dir {
collect_directory_files(backend, &path, snapshot_dir, manifest, options)?;
} else {
add_file_to_manifest(backend, &path, snapshot_dir, manifest, options)?;
}
}
Ok(())
}
fn add_file_to_manifest(
backend: &dyn VfsBackend,
file_path: &PathBuf,
snapshot_dir: &PathBuf,
manifest: &mut BackupManifest,
options: &SendOptions,
) -> Result<(), VfsError> {
let stat = backend.stat(file_path)?;
let relative_path = file_path.strip_prefix(snapshot_dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file_path.to_string_lossy().to_string());
let checksums = if options.include_checksums {
let checksum_dir = snapshot_dir.join(".checksums");
let checksum_file = checksum_dir.join(&relative_path).with_extension(".checksums");
if backend.exists(&checksum_file) {
load_checksum_file(backend, &checksum_file)?
} else {
None
}
} else {
None
};
manifest.add_file(relative_path, stat.size, checksums);
Ok(())
}
fn load_checksum_file(
backend: &dyn VfsBackend,
checksum_path: &PathBuf,
) -> Result<Option<VfsChecksumFile>, VfsError> {
let mut file = backend.open_file(checksum_path, &super::open_flags::OpenFlags::new().read())?;
let data = file.read_all()?;
if data.is_empty() {
return Ok(None);
}
VfsChecksumFile::from_bytes(&data).map(Some)
}
fn collect_snapshot_data(
backend: &dyn VfsBackend,
snapshot_dir: &PathBuf,
) -> Result<Vec<u8>, VfsError> {
let mut buffer = Vec::new();
let entries = backend.read_dir(snapshot_dir)?;
for entry in entries {
if entry.name == MANIFEST_FILE || entry.name == ".meta" || entry.name == ".checksums" {
continue;
}
let file_path = snapshot_dir.join(&entry.name);
if entry.stat.is_dir {
collect_directory_data(backend, &file_path, &mut buffer)?;
} else {
collect_file_data(backend, &file_path, &mut buffer)?;
}
}
Ok(buffer)
}
fn collect_directory_data(
backend: &dyn VfsBackend,
dir: &PathBuf,
buffer: &mut Vec<u8>,
) -> Result<(), VfsError> {
let entries = backend.read_dir(dir)?;
for entry in entries {
let path = dir.join(&entry.name);
if entry.stat.is_dir {
collect_directory_data(backend, &path, buffer)?;
} else {
collect_file_data(backend, &path, buffer)?;
}
}
Ok(())
}
fn collect_file_data(
backend: &dyn VfsBackend,
file_path: &PathBuf,
buffer: &mut Vec<u8>,
) -> Result<(), VfsError> {
let mut file = backend.open_file(file_path, &super::open_flags::OpenFlags::new().read())?;
let data = file.read_all()?;
let path_str = file_path.to_string_lossy();
let path_bytes = path_str.as_bytes();
buffer.extend_from_slice(&(path_bytes.len() as u64).to_be_bytes());
buffer.extend_from_slice(path_bytes);
buffer.extend_from_slice(&(data.len() as u64).to_be_bytes());
buffer.extend_from_slice(&data);
Ok(())
}
fn restore_snapshot_data(
backend: &dyn VfsBackend,
data: &[u8],
snapshot_dir: &PathBuf,
) -> Result<(), VfsError> {
let mut offset = 0;
while offset < data.len() {
if data.len() < offset + 8 {
break;
}
let path_len = u64::from_be_bytes(data[offset..offset+8].try_into().map_err(|_| VfsError::Io("Invalid path length".to_string()))?) as usize;
offset += 8;
if data.len() < offset + path_len {
return Err(VfsError::Io("Truncated path".to_string()));
}
let path_str = String::from_utf8_lossy(&data[offset..offset+path_len]);
let relative_path = PathBuf::from(path_str.as_ref());
offset += path_len;
if data.len() < offset + 8 {
return Err(VfsError::Io("Truncated file length".to_string()));
}
let file_len = u64::from_be_bytes(data[offset..offset+8].try_into().map_err(|_| VfsError::Io("Invalid file length".to_string()))?) as usize;
offset += 8;
if data.len() < offset + file_len {
return Err(VfsError::Io("Truncated file data".to_string()));
}
let file_data = &data[offset..offset+file_len];
offset += file_len;
let file_path = snapshot_dir.join(&relative_path);
let parent = file_path.parent()
.ok_or_else(|| VfsError::Io("Invalid file path".to_string()))?;
if !backend.exists(parent) {
backend.create_dir_all(parent, 0o755)?;
}
let mut file = backend.open_file(
&file_path,
&super::open_flags::OpenFlags::new().write().create().truncate(),
)?;
file.write_all(file_data)?;
file.flush()?;
}
Ok(())
}
fn send_incremental_payload(
backend: &dyn VfsBackend,
from_snap: &str,
to_snap: &str,
root: &PathBuf,
) -> Result<Vec<u8>, VfsError> {
let from_dir = root.join(".snapshots").join(from_snap);
let to_dir = root.join(".snapshots").join(to_snap);
if !backend.exists(&from_dir) || !backend.exists(&to_dir) {
return Err(VfsError::NotFound("Source snapshot not found".to_string()));
}
let from_files = collect_file_set(backend, &from_dir)?;
let to_files = collect_file_set(backend, &to_dir)?;
let mut buffer = Vec::new();
for (relative, to_size) in &to_files {
let changed = !from_files.contains(&(relative.clone(), *to_size));
if changed {
let to_path = to_dir.join(relative);
collect_file_data(backend, &to_path, &mut buffer)?;
}
}
Ok(buffer)
}
fn collect_file_set(
backend: &dyn VfsBackend,
dir: &PathBuf,
) -> Result<HashSet<(String, u64)>, VfsError> {
let mut files = HashSet::new();
let entries = backend.read_dir(dir)?;
for entry in entries {
let path = dir.join(&entry.name);
if entry.stat.is_dir {
let sub_files = collect_file_set(backend, &path)?;
files.extend(sub_files);
} else {
let relative = path.strip_prefix(dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
files.insert((relative, entry.stat.size));
}
}
Ok(files)
}
fn verify_snapshot_checksums(
backend: &dyn VfsBackend,
snapshot_dir: &PathBuf,
root: &PathBuf,
) -> Result<(), VfsError> {
let checksum_dir = snapshot_dir.join(".checksums");
if !backend.exists(&checksum_dir) {
return Ok(());
}
let entries = backend.read_dir(snapshot_dir)?;
for entry in entries {
if entry.stat.is_dir {
continue;
}
let file_path = snapshot_dir.join(&entry.name);
let result = scrub_file(backend, &file_path, root, false)?;
if !result.is_clean() {
return Err(VfsError::Io(format!(
"Checksum verification failed for {}: {} corrupted blocks",
entry.name,
result.corrupted_blocks.len()
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_send_options_default() {
let opts = SendOptions::default();
assert_eq!(opts.format, SendFormat::CustomJson);
assert!(opts.incremental_from.is_none());
assert_eq!(opts.compress, VfsCompression::Zstd);
assert!(!opts.encrypt);
assert!(opts.include_checksums);
}
#[test]
fn test_receive_options_default() {
let opts = ReceiveOptions::default();
assert_eq!(opts.format, SendFormat::CustomJson);
assert!(opts.verify_checksums);
assert!(opts.target_name.is_none());
}
#[test]
fn test_manifest_roundtrip() {
let mut manifest = BackupManifest::new("test_snap".to_string(), PathBuf::from("/data"));
manifest.add_file("file1.txt".to_string(), 1000, None);
manifest.add_file("dir/file2.txt".to_string(), 2000, None);
manifest.calculate_ratio();
assert_eq!(manifest.files.len(), 2);
assert_eq!(manifest.total_size, 3000);
}
#[test]
fn test_stream_format() {
let manifest = BackupManifest::new("test".to_string(), PathBuf::from("/"));
let stream = BackupStream::new(SendFormat::CustomJson, manifest, vec![]);
assert_eq!(stream.format, SendFormat::CustomJson);
}
}

View File

@@ -86,6 +86,7 @@ fn vfs_stat_to_file_info(stat: &VfsStat, name: &str, path: &Path) -> FileInfo {
last_write_time: system_time_to_filetime(stat.mtime),
change_time: system_time_to_filetime(stat.mtime),
is_directory: stat.is_dir,
dos_attributes: 0,
file_index: 0,
}
}

View File

@@ -0,0 +1,319 @@
//! Storage Stats - Metrics for dashboard display
//!
//! Provides storage overview, dedup, compression, RAID stats
use std::path::PathBuf;
use super::{VfsBackend, VfsError, VfsCompression, VfsRaidLevel};
use super::dedup::DedupStats;
use super::raid::VfsRaidBackend;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StorageStats {
pub total_size: u64,
pub used_size: u64,
pub free_size: u64,
pub file_count: u64,
pub dir_count: u64,
pub dedup_ratio: f64,
pub compression_ratio: f64,
pub encryption_enabled: bool,
}
impl StorageStats {
pub fn empty() -> Self {
Self {
total_size: 0,
used_size: 0,
free_size: 0,
file_count: 0,
dir_count: 0,
dedup_ratio: 1.0,
compression_ratio: 1.0,
encryption_enabled: false,
}
}
pub fn format_total(&self) -> String {
format_size(self.total_size)
}
pub fn format_used(&self) -> String {
format_size(self.used_size)
}
pub fn format_free(&self) -> String {
format_size(self.free_size)
}
pub fn usage_percent(&self) -> f64 {
if self.total_size == 0 {
return 0.0;
}
(self.used_size as f64 / self.total_size as f64) * 100.0
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DedupStatsResponse {
pub unique_blocks: u64,
pub total_blocks: u64,
pub stored_bytes: u64,
pub saved_bytes: u64,
pub dedup_ratio: f64,
}
impl From<DedupStats> for DedupStatsResponse {
fn from(stats: DedupStats) -> Self {
let saved = stats.total_blocks * 4096 - stats.stored_bytes;
Self {
unique_blocks: stats.unique_blocks,
total_blocks: stats.total_blocks,
stored_bytes: stats.stored_bytes,
saved_bytes: saved,
dedup_ratio: if stats.total_blocks > 0 {
stats.stored_bytes as f64 / (stats.total_blocks * 4096) as f64
} else {
1.0
},
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CompressionStatsResponse {
pub algorithm: String,
pub original_size: u64,
pub compressed_size: u64,
pub compression_ratio: f64,
}
impl CompressionStatsResponse {
pub fn from_compression(compression: VfsCompression, original: u64, compressed: u64) -> Self {
let algorithm = match compression {
VfsCompression::None => "none",
VfsCompression::Lz4 => "lz4",
VfsCompression::Zstd => "zstd",
};
let ratio = if original > 0 {
compressed as f64 / original as f64
} else {
1.0
};
Self {
algorithm: algorithm.to_string(),
original_size: original,
compressed_size: compressed,
compression_ratio: ratio,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RaidStatsResponse {
pub level: String,
pub disk_count: usize,
pub data_disks: usize,
pub parity_disks: usize,
pub healthy: bool,
pub rebuild_in_progress: bool,
}
impl RaidStatsResponse {
pub fn from_raid(raid: &VfsRaidBackend) -> Self {
let level = match raid.level() {
VfsRaidLevel::Single => "single",
VfsRaidLevel::RaidZ1 => "raidz1",
VfsRaidLevel::RaidZ2 => "raidz2",
VfsRaidLevel::RaidZ3 => "raidz3",
};
Self {
level: level.to_string(),
disk_count: raid.backends().len(),
data_disks: raid.data_disks(),
parity_disks: raid.parity_disks(),
healthy: true,
rebuild_in_progress: false,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ScrubStatsResponse {
pub last_scrub_time: Option<u64>,
pub next_scrub_time: Option<u64>,
pub scrub_count: usize,
pub corrupted_blocks_found: u64,
pub blocks_verified: u64,
pub running: bool,
}
impl ScrubStatsResponse {
pub fn empty() -> Self {
Self {
last_scrub_time: None,
next_scrub_time: None,
scrub_count: 0,
corrupted_blocks_found: 0,
blocks_verified: 0,
running: false,
}
}
pub fn from_scheduler(scheduler: &super::scrub_scheduler::ScrubScheduler) -> Self {
let stats = scheduler.get_stats();
Self {
last_scrub_time: stats.last_scrub_time,
next_scrub_time: stats.next_scrub_time,
scrub_count: stats.scrub_count,
corrupted_blocks_found: 0,
blocks_verified: 0,
running: stats.running,
}
}
}
pub fn calculate_storage_stats(
backend: &dyn VfsBackend,
root: &PathBuf,
) -> Result<StorageStats, VfsError> {
let mut stats = StorageStats::empty();
calculate_recursive(backend, root, &mut stats)?;
Ok(stats)
}
fn calculate_recursive(
backend: &dyn VfsBackend,
path: &PathBuf,
stats: &mut StorageStats,
) -> Result<(), VfsError> {
let entries = backend.read_dir(path)?;
for entry in entries {
if entry.name == ".snapshots" || entry.name == ".checksums" {
continue;
}
let entry_path = path.join(&entry.name);
if entry.stat.is_dir {
stats.dir_count += 1;
calculate_recursive(backend, &entry_path, stats)?;
} else {
stats.file_count += 1;
stats.used_size += entry.stat.size;
}
}
Ok(())
}
fn format_size(size: u64) -> String {
if size < 1024 {
format!("{} B", size)
} else if size < 1024 * 1024 {
format!("{:.2} KB", size as f64 / 1024.0)
} else if size < 1024 * 1024 * 1024 {
format!("{:.2} MB", size as f64 / (1024.0 * 1024.0))
} else if size < 1024 * 1024 * 1024 * 1024 {
format!("{:.2} GB", size as f64 / (1024.0 * 1024.0 * 1024.0))
} else {
format!("{:.2} TB", size as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_stats_empty() {
let stats = StorageStats::empty();
assert_eq!(stats.total_size, 0);
assert_eq!(stats.used_size, 0);
assert_eq!(stats.dedup_ratio, 1.0);
}
#[test]
fn test_format_size_bytes() {
assert_eq!(format_size(512), "512 B");
}
#[test]
fn test_format_size_kb() {
assert_eq!(format_size(1536), "1.50 KB");
}
#[test]
fn test_format_size_mb() {
assert_eq!(format_size(1536 * 1024), "1.50 MB");
}
#[test]
fn test_format_size_gb() {
assert_eq!(format_size(1536 * 1024 * 1024), "1.50 GB");
}
#[test]
fn test_usage_percent() {
let stats = StorageStats {
total_size: 1000,
used_size: 250,
free_size: 750,
file_count: 10,
dir_count: 2,
dedup_ratio: 1.0,
compression_ratio: 1.0,
encryption_enabled: false,
};
assert_eq!(stats.usage_percent(), 25.0);
}
#[test]
fn test_compression_stats() {
let stats = CompressionStatsResponse::from_compression(
VfsCompression::Zstd,
1000,
420,
);
assert_eq!(stats.algorithm, "zstd");
assert_eq!(stats.compression_ratio, 0.42);
}
#[test]
fn test_raid_stats_single() {
let backend: Box<dyn VfsBackend> = Box::new(super::super::local_fs::LocalFs::new());
let config = super::super::VfsRaidConfig {
level: VfsRaidLevel::Single,
stripe_size: 4096,
disk_paths: vec![PathBuf::from("/tmp")],
};
let raid = VfsRaidBackend::new(config, vec![backend]).unwrap();
let stats = RaidStatsResponse::from_raid(&raid);
assert_eq!(stats.level, "single");
assert_eq!(stats.disk_count, 1);
assert_eq!(stats.parity_disks, 0);
}
#[test]
fn test_dedup_stats_conversion() {
let dedup = DedupStats {
total_blocks: 100,
total_refs: 200,
unique_blocks: 50,
stored_bytes: 200 * 1024,
};
let response = DedupStatsResponse::from(dedup);
assert_eq!(response.unique_blocks, 50);
assert_eq!(response.total_blocks, 100);
}
}