Skip to main content

pwr_server/
storage.rs

1//! Project storage backend for pwr-server.
2//!
3//! Manages the on-disk project registry (a JSON index file) and the
4//! per-project directories containing encrypted archive blobs and
5//! metadata. All operations are synchronous (the caller wraps them
6//! in tokio::task::spawn_blocking for async contexts).
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::io::{BufReader, BufWriter, Read, Write};
13use std::path::{Path, PathBuf};
14use uuid::Uuid;
15
16use crate::config::ServerConfig;
17
18/// Represents a project stored on the server.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct StoredProject {
21    pub uuid: Uuid,
22    pub name: String,
23    pub size_bytes: u64,
24    pub file_count: u32,
25    pub encrypted: bool,
26    pub created_at: DateTime<Utc>,
27    pub updated_at: DateTime<Utc>,
28}
29
30/// The project registry, persisted as a JSON file on disk.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32struct Registry {
33    version: u32,
34    projects: HashMap<Uuid, StoredProject>,
35}
36
37impl Registry {
38    fn new() -> Self {
39        Self {
40            version: 1,
41            projects: HashMap::new(),
42        }
43    }
44}
45
46/// Handle to the project storage subsystem.
47///
48/// Wraps the registry and the filesystem root where project data
49/// is stored. Methods that modify the registry save it to disk
50/// atomically after each mutation.
51pub struct ProjectStorage {
52    config: ServerConfig,
53    registry: Registry,
54}
55
56impl ProjectStorage {
57    /// Create or load the project storage.
58    ///
59    /// If the storage directory does not exist, it is created along
60    /// with an empty registry. If it exists, the registry is loaded
61    /// and validated.
62    pub fn new(config: ServerConfig) -> Result<Self, String> {
63        fs::create_dir_all(&config.storage_base_path)
64            .map_err(|e| format!("Cannot create storage dir: {}", e))?;
65
66        let registry_path = config.registry_path();
67        let registry = if registry_path.exists() {
68            Self::load_registry(&registry_path)?
69        } else {
70            let reg = Registry::new();
71            Self::save_registry(&registry_path, &reg)?;
72            reg
73        };
74
75        Ok(Self { config, registry })
76    }
77
78    /// Add a new project to the registry.
79    ///
80    /// Returns an error if a project with the same UUID already exists.
81    pub fn add_project(&mut self, project: StoredProject) -> Result<(), String> {
82        if self.registry.projects.contains_key(&project.uuid) {
83            return Err(format!(
84                "Project {} ({}) already exists",
85                project.name, project.uuid
86            ));
87        }
88
89        // Ensure the project storage directory exists
90        let project_dir = self.config.project_dir(&project.uuid);
91        fs::create_dir_all(&project_dir)
92            .map_err(|e| format!("Cannot create project dir: {}", e))?;
93
94        self.registry
95            .projects
96            .insert(project.uuid, project);
97        self.flush()
98    }
99
100    /// Remove a project from the registry and delete its files.
101    pub fn remove_project(&mut self, uuid: &Uuid) -> Result<(), String> {
102        if self.registry.projects.remove(uuid).is_none() {
103            return Err(format!("Project {} not found", uuid));
104        }
105
106        // Delete project directory
107        let project_dir = self.config.project_dir(uuid);
108        if project_dir.exists() {
109            fs::remove_dir_all(&project_dir)
110                .map_err(|e| format!("Cannot delete project dir: {}", e))?;
111        }
112
113        self.flush()
114    }
115
116    /// Get a project by UUID.
117    pub fn get_project(&self, uuid: &Uuid) -> Option<&StoredProject> {
118        self.registry.projects.get(uuid)
119    }
120
121    /// List all projects in the registry.
122    pub fn list_projects(&self) -> Vec<&StoredProject> {
123        self.registry.projects.values().collect()
124    }
125
126    /// Update a project's metadata in the registry.
127    pub fn update_project(&mut self, project: StoredProject) -> Result<(), String> {
128        if !self.registry.projects.contains_key(&project.uuid) {
129            return Err(format!("Project {} not found", project.uuid));
130        }
131        self.registry
132            .projects
133            .insert(project.uuid, project);
134        self.flush()
135    }
136
137    /// Return the path where a project's archive blob is stored.
138    pub fn archive_path(&self, uuid: &Uuid) -> PathBuf {
139        self.config.project_dir(uuid).join("data.enc")
140    }
141
142    /// Return the path where a project's metadata TOML is stored.
143    pub fn meta_path(&self, uuid: &Uuid) -> PathBuf {
144        self.config.project_dir(uuid).join("meta.toml")
145    }
146
147    /// Write a project's archive blob to disk using streaming I/O.
148    ///
149    /// Data is read from the provided reader and written to the
150    /// archive path. The caller is responsible for encryption —
151    /// the server stores whatever bytes it receives.
152    pub fn write_archive(
153        &self,
154        uuid: &Uuid,
155        reader: &mut impl Read,
156    ) -> Result<u64, String> {
157        let path = self.archive_path(uuid);
158        let file = fs::File::create(&path)
159            .map_err(|e| format!("Cannot create archive file: {}", e))?;
160        let mut writer = BufWriter::new(file);
161
162        let bytes_written = std::io::copy(reader, &mut writer)
163            .map_err(|e| format!("Cannot write archive data: {}", e))?;
164
165        writer
166            .flush()
167            .map_err(|e| format!("Cannot flush archive: {}", e))?;
168
169        Ok(bytes_written)
170    }
171
172    /// Open a project's archive blob for reading.
173    ///
174    /// Returns a buffered reader positioned at the start of the file.
175    pub fn read_archive(&self, uuid: &Uuid) -> Result<BufReader<fs::File>, String> {
176        let path = self.archive_path(uuid);
177        if !path.exists() {
178            return Err(format!(
179                "Archive file not found for project {}",
180                uuid
181            ));
182        }
183        let file = fs::File::open(&path)
184            .map_err(|e| format!("Cannot open archive: {}", e))?;
185        Ok(BufReader::new(file))
186    }
187
188    /// Check whether an archive blob exists for a project.
189    pub fn archive_exists(&self, uuid: &Uuid) -> bool {
190        self.archive_path(uuid).exists()
191    }
192
193    /// Write a project's per-project metadata TOML file.
194    pub fn write_meta(&self, uuid: &Uuid, project: &StoredProject) -> Result<(), String> {
195        let path = self.meta_path(uuid);
196        let contents = toml::to_string_pretty(project)
197            .map_err(|e| format!("Cannot serialize meta: {}", e))?;
198        fs::write(&path, contents)
199            .map_err(|e| format!("Cannot write meta file: {}", e))?;
200        Ok(())
201    }
202
203    /// Check that the total stored size does not exceed the configured limit.
204    pub fn check_size_limit(&self, additional_bytes: u64) -> Result<(), String> {
205        let current_total: u64 = self
206            .registry
207            .projects
208            .values()
209            .map(|p| p.size_bytes)
210            .sum();
211        let max_bytes = self.config.max_project_size_gb * 1024 * 1024 * 1024;
212        let new_total = current_total + additional_bytes;
213
214        if new_total > max_bytes {
215            return Err(format!(
216                "Storage limit exceeded: would use {} GB (limit {} GB)",
217                new_total / (1024 * 1024 * 1024),
218                self.config.max_project_size_gb
219            ));
220        }
221        Ok(())
222    }
223
224    // Private helpers
225
226    fn load_registry(path: &Path) -> Result<Registry, String> {
227        let contents = fs::read_to_string(path)
228            .map_err(|e| format!("Cannot read registry: {}", e))?;
229        serde_json::from_str(&contents)
230            .map_err(|e| format!("Cannot parse registry: {}", e))
231    }
232
233    fn save_registry(path: &Path, registry: &Registry) -> Result<(), String> {
234        let tmp_path = path.with_extension("tmp");
235        let contents = serde_json::to_string_pretty(registry)
236            .map_err(|e| format!("Cannot serialize registry: {}", e))?;
237
238        // Atomic write
239        fs::write(&tmp_path, contents)
240            .map_err(|e| format!("Cannot write registry tmp: {}", e))?;
241        fs::rename(&tmp_path, path)
242            .map_err(|e| format!("Cannot rename registry: {}", e))?;
243
244        Ok(())
245    }
246
247    fn flush(&self) -> Result<(), String> {
248        Self::save_registry(&self.config.registry_path(), &self.registry)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use tempfile::TempDir;
256
257    fn make_config(dir: &Path) -> ServerConfig {
258        let mut cfg = ServerConfig::default();
259        cfg.storage_base_path = dir.join("storage");
260        cfg.auth_token = "test-token".into();
261        cfg
262    }
263
264    fn make_project(name: &str) -> StoredProject {
265        StoredProject {
266            uuid: Uuid::new_v4(),
267            name: name.into(),
268            size_bytes: 1_000_000,
269            file_count: 42,
270            encrypted: false,
271            created_at: Utc::now(),
272            updated_at: Utc::now(),
273        }
274    }
275
276    #[test]
277    fn test_create_and_list() {
278        let tmp = TempDir::new().unwrap();
279        let config = make_config(tmp.path());
280        let mut storage = ProjectStorage::new(config).unwrap();
281
282        let p1 = make_project("alpha");
283        let p2 = make_project("beta");
284
285        storage.add_project(p1.clone()).unwrap();
286        storage.add_project(p2.clone()).unwrap();
287
288        let list = storage.list_projects();
289        assert_eq!(list.len(), 2);
290    }
291
292    #[test]
293    fn test_duplicate_rejected() {
294        let tmp = TempDir::new().unwrap();
295        let config = make_config(tmp.path());
296        let mut storage = ProjectStorage::new(config).unwrap();
297
298        let p = make_project("test");
299        storage.add_project(p.clone()).unwrap();
300        assert!(storage.add_project(p).is_err());
301    }
302
303    #[test]
304    fn test_remove_project() {
305        let tmp = TempDir::new().unwrap();
306        let config = make_config(tmp.path());
307        let mut storage = ProjectStorage::new(config).unwrap();
308
309        let p = make_project("removable");
310        let uuid = p.uuid;
311        storage.add_project(p).unwrap();
312        assert!(storage.get_project(&uuid).is_some());
313
314        storage.remove_project(&uuid).unwrap();
315        assert!(storage.get_project(&uuid).is_none());
316    }
317
318    #[test]
319    fn test_write_and_read_archive() {
320        let tmp = TempDir::new().unwrap();
321        let config = make_config(tmp.path());
322        let mut storage = ProjectStorage::new(config).unwrap();
323
324        let p = make_project("data-test");
325        let uuid = p.uuid;
326        storage.add_project(p).unwrap();
327
328        let data = b"encrypted project archive contents here";
329        let written = storage
330            .write_archive(&uuid, &mut &data[..])
331            .unwrap();
332        assert_eq!(written as usize, data.len());
333
334        let mut reader = storage.read_archive(&uuid).unwrap();
335        let mut read_back = Vec::new();
336        reader.read_to_end(&mut read_back).unwrap();
337        assert_eq!(read_back, data);
338    }
339
340    #[test]
341    fn test_persistence() {
342        let tmp = TempDir::new().unwrap();
343        let config = make_config(tmp.path());
344        let uuid;
345
346        {
347            let mut storage = ProjectStorage::new(config.clone()).unwrap();
348            let p = make_project("persistent");
349            uuid = p.uuid;
350            storage.add_project(p).unwrap();
351        }
352
353        // Re-open
354        {
355            let storage = ProjectStorage::new(config).unwrap();
356            let retrieved = storage.get_project(&uuid).unwrap();
357            assert_eq!(retrieved.name, "persistent");
358        }
359    }
360}