use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::config::ServerConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProject {
pub uuid: Uuid,
pub name: String,
pub size_bytes: u64,
pub file_count: u32,
pub encrypted: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Registry {
version: u32,
projects: HashMap<Uuid, StoredProject>,
}
impl Registry {
fn new() -> Self {
Self {
version: 1,
projects: HashMap::new(),
}
}
}
pub struct ProjectStorage {
config: ServerConfig,
registry: Registry,
}
impl ProjectStorage {
pub fn new(config: ServerConfig) -> Result<Self, String> {
fs::create_dir_all(&config.storage_base_path)
.map_err(|e| format!("Cannot create storage dir: {}", e))?;
let registry_path = config.registry_path();
let registry = if registry_path.exists() {
Self::load_registry(®istry_path)?
} else {
let reg = Registry::new();
Self::save_registry(®istry_path, ®)?;
reg
};
Ok(Self { config, registry })
}
pub fn add_project(&mut self, project: StoredProject) -> Result<(), String> {
if self.registry.projects.contains_key(&project.uuid) {
return Err(format!(
"Project {} ({}) already exists",
project.name, project.uuid
));
}
let project_dir = self.config.project_dir(&project.uuid);
fs::create_dir_all(&project_dir)
.map_err(|e| format!("Cannot create project dir: {}", e))?;
self.registry
.projects
.insert(project.uuid, project);
self.flush()
}
pub fn remove_project(&mut self, uuid: &Uuid) -> Result<(), String> {
if self.registry.projects.remove(uuid).is_none() {
return Err(format!("Project {} not found", uuid));
}
let project_dir = self.config.project_dir(uuid);
if project_dir.exists() {
fs::remove_dir_all(&project_dir)
.map_err(|e| format!("Cannot delete project dir: {}", e))?;
}
self.flush()
}
pub fn get_project(&self, uuid: &Uuid) -> Option<&StoredProject> {
self.registry.projects.get(uuid)
}
pub fn list_projects(&self) -> Vec<&StoredProject> {
self.registry.projects.values().collect()
}
pub fn update_project(&mut self, project: StoredProject) -> Result<(), String> {
if !self.registry.projects.contains_key(&project.uuid) {
return Err(format!("Project {} not found", project.uuid));
}
self.registry
.projects
.insert(project.uuid, project);
self.flush()
}
pub fn archive_path(&self, uuid: &Uuid) -> PathBuf {
self.config.project_dir(uuid).join("data.enc")
}
pub fn meta_path(&self, uuid: &Uuid) -> PathBuf {
self.config.project_dir(uuid).join("meta.toml")
}
pub fn write_archive(
&self,
uuid: &Uuid,
reader: &mut impl Read,
) -> Result<u64, String> {
let path = self.archive_path(uuid);
let file = fs::File::create(&path)
.map_err(|e| format!("Cannot create archive file: {}", e))?;
let mut writer = BufWriter::new(file);
let bytes_written = std::io::copy(reader, &mut writer)
.map_err(|e| format!("Cannot write archive data: {}", e))?;
writer
.flush()
.map_err(|e| format!("Cannot flush archive: {}", e))?;
Ok(bytes_written)
}
pub fn read_archive(&self, uuid: &Uuid) -> Result<BufReader<fs::File>, String> {
let path = self.archive_path(uuid);
if !path.exists() {
return Err(format!(
"Archive file not found for project {}",
uuid
));
}
let file = fs::File::open(&path)
.map_err(|e| format!("Cannot open archive: {}", e))?;
Ok(BufReader::new(file))
}
pub fn archive_exists(&self, uuid: &Uuid) -> bool {
self.archive_path(uuid).exists()
}
pub fn write_meta(&self, uuid: &Uuid, project: &StoredProject) -> Result<(), String> {
let path = self.meta_path(uuid);
let contents = toml::to_string_pretty(project)
.map_err(|e| format!("Cannot serialize meta: {}", e))?;
fs::write(&path, contents)
.map_err(|e| format!("Cannot write meta file: {}", e))?;
Ok(())
}
pub fn check_size_limit(&self, additional_bytes: u64) -> Result<(), String> {
let current_total: u64 = self
.registry
.projects
.values()
.map(|p| p.size_bytes)
.sum();
let max_bytes = self.config.max_project_size_gb * 1024 * 1024 * 1024;
let new_total = current_total + additional_bytes;
if new_total > max_bytes {
return Err(format!(
"Storage limit exceeded: would use {} GB (limit {} GB)",
new_total / (1024 * 1024 * 1024),
self.config.max_project_size_gb
));
}
Ok(())
}
fn load_registry(path: &Path) -> Result<Registry, String> {
let contents = fs::read_to_string(path)
.map_err(|e| format!("Cannot read registry: {}", e))?;
serde_json::from_str(&contents)
.map_err(|e| format!("Cannot parse registry: {}", e))
}
fn save_registry(path: &Path, registry: &Registry) -> Result<(), String> {
let tmp_path = path.with_extension("tmp");
let contents = serde_json::to_string_pretty(registry)
.map_err(|e| format!("Cannot serialize registry: {}", e))?;
fs::write(&tmp_path, contents)
.map_err(|e| format!("Cannot write registry tmp: {}", e))?;
fs::rename(&tmp_path, path)
.map_err(|e| format!("Cannot rename registry: {}", e))?;
Ok(())
}
fn flush(&self) -> Result<(), String> {
Self::save_registry(&self.config.registry_path(), &self.registry)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_config(dir: &Path) -> ServerConfig {
let mut cfg = ServerConfig::default();
cfg.storage_base_path = dir.join("storage");
cfg.auth_token = "test-token".into();
cfg
}
fn make_project(name: &str) -> StoredProject {
StoredProject {
uuid: Uuid::new_v4(),
name: name.into(),
size_bytes: 1_000_000,
file_count: 42,
encrypted: false,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_create_and_list() {
let tmp = TempDir::new().unwrap();
let config = make_config(tmp.path());
let mut storage = ProjectStorage::new(config).unwrap();
let p1 = make_project("alpha");
let p2 = make_project("beta");
storage.add_project(p1.clone()).unwrap();
storage.add_project(p2.clone()).unwrap();
let list = storage.list_projects();
assert_eq!(list.len(), 2);
}
#[test]
fn test_duplicate_rejected() {
let tmp = TempDir::new().unwrap();
let config = make_config(tmp.path());
let mut storage = ProjectStorage::new(config).unwrap();
let p = make_project("test");
storage.add_project(p.clone()).unwrap();
assert!(storage.add_project(p).is_err());
}
#[test]
fn test_remove_project() {
let tmp = TempDir::new().unwrap();
let config = make_config(tmp.path());
let mut storage = ProjectStorage::new(config).unwrap();
let p = make_project("removable");
let uuid = p.uuid;
storage.add_project(p).unwrap();
assert!(storage.get_project(&uuid).is_some());
storage.remove_project(&uuid).unwrap();
assert!(storage.get_project(&uuid).is_none());
}
#[test]
fn test_write_and_read_archive() {
let tmp = TempDir::new().unwrap();
let config = make_config(tmp.path());
let mut storage = ProjectStorage::new(config).unwrap();
let p = make_project("data-test");
let uuid = p.uuid;
storage.add_project(p).unwrap();
let data = b"encrypted project archive contents here";
let written = storage
.write_archive(&uuid, &mut &data[..])
.unwrap();
assert_eq!(written as usize, data.len());
let mut reader = storage.read_archive(&uuid).unwrap();
let mut read_back = Vec::new();
reader.read_to_end(&mut read_back).unwrap();
assert_eq!(read_back, data);
}
#[test]
fn test_persistence() {
let tmp = TempDir::new().unwrap();
let config = make_config(tmp.path());
let uuid;
{
let mut storage = ProjectStorage::new(config.clone()).unwrap();
let p = make_project("persistent");
uuid = p.uuid;
storage.add_project(p).unwrap();
}
{
let storage = ProjectStorage::new(config).unwrap();
let retrieved = storage.get_project(&uuid).unwrap();
assert_eq!(retrieved.name, "persistent");
}
}
}