sentinel_dbms/
store.rs

1use std::{
2    path::{Path, PathBuf},
3    sync::Arc,
4};
5
6use tokio::fs as tokio_fs;
7use tracing::{debug, error, trace, warn};
8
9use crate::{
10    validation::{is_reserved_name, is_valid_name_chars},
11    Collection,
12    Result,
13    SentinelError,
14};
15
16/// The top-level manager for document collections in Cyberpath Sentinel.
17///
18/// `Store` manages the root directory where all collections are stored. It handles
19/// directory creation, collection access, and serves as the entry point for all
20/// document storage operations. Each `Store` instance corresponds to a single
21/// filesystem-backed database.
22///
23/// # Architecture
24///
25/// The Store creates a hierarchical structure:
26/// - Root directory (specified at creation)
27///   - `data/` subdirectory (contains all collections)
28///     - Collection directories (e.g., `users/`, `audit_logs/`)
29///
30/// # Examples
31///
32/// ```no_run
33/// use sentinel_dbms::Store;
34///
35/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
36/// // Create a new store at the specified path
37/// let store =
38///     Store::new("/var/lib/sentinel/db", Some("my_passphrase")).await?;
39///
40/// // Access a collection
41/// let users = store.collection("users").await?;
42/// # Ok(())
43/// # }
44/// ```
45///
46/// # Thread Safety
47///
48/// `Store` is safe to share across threads. Multiple collections can be accessed
49/// concurrently, with each collection managing its own locking internally.
50#[derive(Debug)]
51pub struct Store {
52    /// The root path of the store.
53    root_path:   PathBuf,
54    /// The signing key for the store.
55    signing_key: Option<Arc<sentinel_crypto::SigningKey>>,
56}
57
58impl Store {
59    /// Creates a new `Store` instance at the specified root path.
60    ///
61    /// This method initializes the store by creating the root directory if it doesn't
62    /// exist. It does not create the `data/` subdirectory until collections are accessed.
63    ///
64    /// # Parameters
65    ///
66    /// * `root_path` - The filesystem path where the store will be created. This can be any type
67    ///   that implements `AsRef<Path>`, including `&str`, `String`, `Path`, and `PathBuf`.
68    ///
69    /// # Returns
70    ///
71    /// * `Result<Self>` - Returns a new `Store` instance on success, or a `SentinelError` if:
72    ///   - The directory cannot be created due to permission issues
73    ///   - The path is invalid or cannot be accessed
74    ///   - I/O errors occur during directory creation
75    ///
76    /// # Examples
77    ///
78    /// ```no_run
79    /// use sentinel_dbms::Store;
80    ///
81    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
82    /// // Create a store with a string path
83    /// let store = Store::new("/var/lib/sentinel", None).await?;
84    ///
85    /// // Create a store with a PathBuf
86    /// use std::path::PathBuf;
87    /// let path = PathBuf::from("/tmp/my-store");
88    /// let store = Store::new(path, None).await?;
89    ///
90    /// // Create a store in a temporary directory
91    /// let temp_dir = std::env::temp_dir().join("sentinel-test");
92    /// let store = Store::new(&temp_dir, None).await?;
93    /// # Ok(())
94    /// # }
95    /// ```
96    ///
97    /// # Notes
98    ///
99    /// - If the directory already exists, this method succeeds without modification
100    /// - Parent directories are created automatically if they don't exist
101    /// - The created directory will have default permissions set by the operating system
102    pub async fn new<P>(root_path: P, passphrase: Option<&str>) -> Result<Self>
103    where
104        P: AsRef<Path>,
105    {
106        trace!("Creating new Store at path: {:?}", root_path.as_ref());
107        let root_path = root_path.as_ref().to_path_buf();
108        tokio_fs::create_dir_all(&root_path).await.map_err(|e| {
109            error!(
110                "Failed to create store root directory {:?}: {}",
111                root_path, e
112            );
113            e
114        })?;
115        debug!(
116            "Store root directory created or already exists: {:?}",
117            root_path
118        );
119        let mut store = Self {
120            root_path,
121            signing_key: None,
122        };
123        if let Some(passphrase) = passphrase {
124            debug!("Passphrase provided, handling signing key");
125            let keys_collection = store.collection(".keys").await?;
126            if let Some(doc) = keys_collection.get("signing_key").await? {
127                // Load existing signing key
128                debug!("Loading existing signing key from store");
129                let data = doc.data();
130                let encrypted = data["encrypted"].as_str().ok_or_else(|| {
131                    error!("Stored signing key document missing 'encrypted' field");
132                    SentinelError::StoreCorruption {
133                        reason: "stored signing key document missing 'encrypted' field or not a string".to_owned(),
134                    }
135                })?;
136                let salt_hex = data["salt"].as_str().ok_or_else(|| {
137                    error!("Stored signing key document missing 'salt' field");
138                    SentinelError::StoreCorruption {
139                        reason: "stored signing key document missing 'salt' field or not a string".to_owned(),
140                    }
141                })?;
142                let salt = hex::decode(salt_hex).map_err(|err| {
143                    error!("Stored signing key salt is not valid hex: {}", err);
144                    SentinelError::StoreCorruption {
145                        reason: format!("stored signing key salt is not valid hex ({})", err),
146                    }
147                })?;
148                let encryption_key = sentinel_crypto::derive_key_from_passphrase_with_salt(passphrase, &salt)?;
149                let key_bytes = sentinel_crypto::decrypt_data(encrypted, &encryption_key)?;
150                let key_array: [u8; 32] = key_bytes.try_into().map_err(|kb: Vec<u8>| {
151                    error!(
152                        "Stored signing key has invalid length: {}, expected 32",
153                        kb.len()
154                    );
155                    SentinelError::StoreCorruption {
156                        reason: format!(
157                            "stored signing key has an invalid length ({}, expected 32)",
158                            kb.len()
159                        ),
160                    }
161                })?;
162                let signing_key = sentinel_crypto::SigningKey::from_bytes(&key_array);
163                store.signing_key = Some(Arc::new(signing_key));
164                debug!("Existing signing key loaded successfully");
165            }
166            else {
167                // Generate new signing key and salt
168                debug!("Generating new signing key");
169                let (salt, encryption_key) = sentinel_crypto::derive_key_from_passphrase(passphrase)?;
170                let signing_key = sentinel_crypto::SigningKeyManager::generate_key();
171                let key_bytes = signing_key.to_bytes();
172                let encrypted = sentinel_crypto::encrypt_data(&key_bytes, &encryption_key)?;
173                let salt_hex = hex::encode(&salt);
174                keys_collection
175                    .insert(
176                        "signing_key",
177                        serde_json::json!({"encrypted": encrypted, "salt": salt_hex}),
178                    )
179                    .await?;
180                store.signing_key = Some(Arc::new(signing_key));
181                debug!("New signing key generated and stored");
182            }
183        }
184        trace!("Store created successfully");
185        Ok(store)
186    }
187
188    /// Retrieves or creates a collection with the specified name.
189    ///
190    /// This method provides access to a named collection within the store. If the
191    /// collection directory doesn't exist, it will be created automatically under
192    /// the `data/` subdirectory of the store's root path.
193    ///
194    /// # Parameters
195    ///
196    /// * `name` - The name of the collection. This will be used as the directory name under
197    ///   `data/`. The name should be filesystem-safe (avoid special characters that are invalid in
198    ///   directory names on your target platform).
199    ///
200    /// # Returns
201    ///
202    /// * `Result<Collection>` - Returns a `Collection` instance on success, or a `SentinelError`
203    ///   if:
204    ///   - The collection directory cannot be created due to permission issues
205    ///   - The name contains invalid characters for the filesystem
206    ///   - I/O errors occur during directory creation
207    ///
208    /// # Examples
209    ///
210    /// ```no_run
211    /// use sentinel_dbms::Store;
212    /// use serde_json::json;
213    ///
214    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
215    /// let store = Store::new("/var/lib/sentinel", None).await?;
216    ///
217    /// // Access a users collection
218    /// let users = store.collection("users").await?;
219    ///
220    /// // Insert a document into the collection
221    /// users.insert("user-123", json!({
222    ///     "name": "Alice",
223    ///     "email": "alice@example.com"
224    /// })).await?;
225    ///
226    /// // Access multiple collections
227    /// let audit_logs = store.collection("audit_logs").await?;
228    /// let certificates = store.collection("certificates").await?;
229    /// # Ok(())
230    /// # }
231    /// ```
232    ///
233    /// # Collection Naming
234    ///
235    /// Collection names should follow these guidelines:
236    /// - Use lowercase letters, numbers, underscores, and hyphens
237    /// - Avoid spaces and special characters
238    /// - Keep names descriptive but concise (e.g., `users`, `audit_logs`, `api_keys`)
239    ///
240    /// # Notes
241    ///
242    /// - Calling this method multiple times with the same name returns separate `Collection`
243    ///   instances pointing to the same directory
244    /// - The `data/` subdirectory is created automatically on first collection access
245    /// - Collections are not cached; each call creates a new `Collection` instance
246    /// - No validation is performed on the collection name beyond filesystem constraints
247    pub async fn collection(&self, name: &str) -> Result<Collection> {
248        trace!("Accessing collection: {}", name);
249        validate_collection_name(name)?;
250        let path = self.root_path.join("data").join(name);
251        tokio_fs::create_dir_all(&path).await.map_err(|e| {
252            error!("Failed to create collection directory {:?}: {}", path, e);
253            e
254        })?;
255        debug!("Collection directory ensured: {:?}", path);
256        trace!("Collection '{}' accessed successfully", name);
257        Ok(Collection {
258            path,
259            signing_key: self.signing_key.clone(),
260        })
261    }
262
263    pub fn set_signing_key(&mut self, key: sentinel_crypto::SigningKey) { self.signing_key = Some(Arc::new(key)); }
264}
265
266/// Validates that a collection name is filesystem-safe across all platforms.
267///
268/// # Rules
269/// - Must not be empty
270/// - Must not contain path separators (`/` or `\`)
271/// - Must not contain control characters (0x00-0x1F, 0x7F)
272/// - Must not be a Windows reserved name (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
273/// - Must not start with a dot (.) to avoid hidden directories
274/// - Must only contain alphanumeric characters, underscores (_), hyphens (-), and dots (.)
275/// - Must not end with a dot or space (Windows limitation)
276///
277/// # Parameters
278/// - `name`: The collection name to validate
279///
280/// # Returns
281/// - `Ok(())` if the name is valid
282/// - `Err(SentinelError::InvalidCollectionName)` if the name is invalid
283///
284/// # Examples
285/// ```no_run
286/// # use sentinel_dbms::{Store, SentinelError};
287/// # use std::path::Path;
288/// # async fn example() -> Result<(), SentinelError> {
289/// let store = Store::new(Path::new("/tmp/test"), None).await?;
290///
291/// // Valid names
292/// assert!(store.collection("users").await.is_ok());
293/// assert!(store.collection("user_data").await.is_ok());
294/// assert!(store.collection("data-2024").await.is_ok());
295/// assert!(store.collection("test_collection_123").await.is_ok());
296///
297/// // Invalid names
298/// assert!(store.collection("").await.is_err());
299/// assert!(store.collection(".hidden").await.is_err());
300/// assert!(store.collection("path/traversal").await.is_err());
301/// assert!(store.collection("CON").await.is_err());
302/// # Ok(())
303/// # }
304/// ```
305fn validate_collection_name(name: &str) -> Result<()> {
306    trace!("Validating collection name: {}", name);
307    // Check if name is empty
308    if name.is_empty() {
309        debug!("Collection name is empty");
310        return Err(SentinelError::InvalidCollectionName {
311            name: name.to_owned(),
312        });
313    }
314
315    // Check if name starts with a dot (hidden directory)
316    if name.starts_with('.') && name != ".keys" {
317        debug!("Collection name starts with dot and is not .keys: {}", name);
318        return Err(SentinelError::InvalidCollectionName {
319            name: name.to_owned(),
320        });
321    }
322
323    // Check if name ends with a dot or space (Windows limitation)
324    if name.ends_with('.') || name.ends_with(' ') {
325        warn!("Collection name ends with dot or space: {}", name);
326        return Err(SentinelError::InvalidCollectionName {
327            name: name.to_owned(),
328        });
329    }
330
331    // Check for valid characters
332    if !is_valid_name_chars(name) {
333        debug!("Collection name contains invalid characters: {}", name);
334        return Err(SentinelError::InvalidCollectionName {
335            name: name.to_owned(),
336        });
337    }
338
339    // Check for Windows reserved names
340    if is_reserved_name(name) {
341        debug!("Collection name is a reserved name: {}", name);
342        return Err(SentinelError::InvalidCollectionName {
343            name: name.to_owned(),
344        });
345    }
346
347    trace!("Collection name '{}' is valid", name);
348    Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353    use tempfile::tempdir;
354
355    use super::*;
356
357    #[tokio::test]
358    async fn test_store_new_creates_directory() {
359        let temp_dir = tempdir().unwrap();
360        let store_path = temp_dir.path().join("store");
361
362        let _store = Store::new(&store_path, None).await.unwrap();
363        assert!(store_path.exists());
364        assert!(store_path.is_dir());
365    }
366
367    #[tokio::test]
368    async fn test_store_new_with_existing_directory() {
369        let temp_dir = tempdir().unwrap();
370        let store_path = temp_dir.path();
371
372        // Directory already exists
373        let _store = Store::new(&store_path, None).await.unwrap();
374        assert!(store_path.exists());
375    }
376
377    #[tokio::test]
378    async fn test_store_collection_creates_subdirectory() {
379        let temp_dir = tempdir().unwrap();
380        let store = Store::new(temp_dir.path(), None).await.unwrap();
381
382        let collection = store.collection("users").await.unwrap();
383        assert!(collection.path.exists());
384        assert!(collection.path.is_dir());
385        assert_eq!(collection.name(), "users");
386    }
387
388    #[tokio::test]
389    async fn test_store_collection_with_valid_special_characters() {
390        let temp_dir = tempdir().unwrap();
391        let store = Store::new(temp_dir.path(), None).await.unwrap();
392
393        // Test valid names with underscores, hyphens, and dots
394        let collection = store.collection("user_data-123").await.unwrap();
395        assert!(collection.path.exists());
396        assert_eq!(collection.name(), "user_data-123");
397
398        let collection2 = store.collection("test.collection").await.unwrap();
399        assert!(collection2.path.exists());
400        assert_eq!(collection2.name(), "test.collection");
401
402        let collection3 = store.collection("data_2024-v1.0").await.unwrap();
403        assert!(collection3.path.exists());
404        assert_eq!(collection3.name(), "data_2024-v1.0");
405    }
406
407    #[tokio::test]
408    async fn test_store_collection_multiple_calls() {
409        let temp_dir = tempdir().unwrap();
410        let store = Store::new(temp_dir.path(), None).await.unwrap();
411
412        let coll1 = store.collection("users").await.unwrap();
413        let coll2 = store.collection("users").await.unwrap();
414
415        assert_eq!(coll1.name(), coll2.name());
416        assert_eq!(coll1.path, coll2.path);
417    }
418
419    #[tokio::test]
420    async fn test_store_collection_invalid_empty_name() {
421        let temp_dir = tempdir().unwrap();
422        let store = Store::new(temp_dir.path(), None).await.unwrap();
423
424        let result = store.collection("").await;
425        assert!(result.is_err());
426        assert!(matches!(
427            result.unwrap_err(),
428            SentinelError::InvalidCollectionName { .. }
429        ));
430    }
431
432    #[tokio::test]
433    async fn test_store_collection_invalid_path_separator() {
434        let temp_dir = tempdir().unwrap();
435        let store = Store::new(temp_dir.path(), None).await.unwrap();
436
437        // Forward slash
438        let result = store.collection("path/traversal").await;
439        assert!(result.is_err());
440        assert!(matches!(
441            result.unwrap_err(),
442            SentinelError::InvalidCollectionName { .. }
443        ));
444
445        // Backslash
446        let result = store.collection("path\\traversal").await;
447        assert!(result.is_err());
448        assert!(matches!(
449            result.unwrap_err(),
450            SentinelError::InvalidCollectionName { .. }
451        ));
452    }
453
454    #[tokio::test]
455    async fn test_store_collection_invalid_hidden_name() {
456        let temp_dir = tempdir().unwrap();
457        let store = Store::new(temp_dir.path(), None).await.unwrap();
458
459        let result = store.collection(".hidden").await;
460        assert!(result.is_err());
461        assert!(matches!(
462            result.unwrap_err(),
463            SentinelError::InvalidCollectionName { .. }
464        ));
465    }
466
467    #[tokio::test]
468    async fn test_store_collection_invalid_windows_reserved_names() {
469        let temp_dir = tempdir().unwrap();
470        let store = Store::new(temp_dir.path(), None).await.unwrap();
471
472        let reserved_names = vec!["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"];
473        for name in reserved_names {
474            let result = store.collection(name).await;
475            assert!(result.is_err(), "Expected '{}' to be invalid", name);
476            assert!(matches!(
477                result.unwrap_err(),
478                SentinelError::InvalidCollectionName { .. }
479            ));
480
481            // Test lowercase version
482            let result = store.collection(&name.to_lowercase()).await;
483            assert!(
484                result.is_err(),
485                "Expected '{}' to be invalid",
486                name.to_lowercase()
487            );
488            assert!(matches!(
489                result.unwrap_err(),
490                SentinelError::InvalidCollectionName { .. }
491            ));
492        }
493    }
494
495    #[tokio::test]
496    async fn test_store_collection_invalid_control_characters() {
497        let temp_dir = tempdir().unwrap();
498        let store = Store::new(temp_dir.path(), None).await.unwrap();
499
500        // Test null byte
501        let result = store.collection("test\0name").await;
502        assert!(result.is_err());
503        assert!(matches!(
504            result.unwrap_err(),
505            SentinelError::InvalidCollectionName { .. }
506        ));
507
508        // Test other control characters
509        let result = store.collection("test\x01name").await;
510        assert!(result.is_err());
511        assert!(matches!(
512            result.unwrap_err(),
513            SentinelError::InvalidCollectionName { .. }
514        ));
515    }
516
517    #[tokio::test]
518    async fn test_store_collection_invalid_special_characters() {
519        let temp_dir = tempdir().unwrap();
520        let store = Store::new(temp_dir.path(), None).await.unwrap();
521
522        let invalid_chars = vec!["<", ">", ":", "\"", "|", "?", "*"];
523        for ch in invalid_chars {
524            let name = format!("test{}name", ch);
525            let result = store.collection(&name).await;
526            assert!(result.is_err(), "Expected name with '{}' to be invalid", ch);
527            assert!(matches!(
528                result.unwrap_err(),
529                SentinelError::InvalidCollectionName { .. }
530            ));
531        }
532    }
533
534    #[tokio::test]
535    async fn test_store_collection_invalid_trailing_dot_or_space() {
536        let temp_dir = tempdir().unwrap();
537        let store = Store::new(temp_dir.path(), None).await.unwrap();
538
539        // Trailing dot
540        let result = store.collection("test.").await;
541        assert!(result.is_err());
542        assert!(matches!(
543            result.unwrap_err(),
544            SentinelError::InvalidCollectionName { .. }
545        ));
546
547        // Trailing space
548        let result = store.collection("test ").await;
549        assert!(result.is_err());
550        assert!(matches!(
551            result.unwrap_err(),
552            SentinelError::InvalidCollectionName { .. }
553        ));
554    }
555
556    #[tokio::test]
557    async fn test_store_collection_valid_edge_cases() {
558        let temp_dir = tempdir().unwrap();
559        let store = Store::new(temp_dir.path(), None).await.unwrap();
560
561        // Single character
562        let collection = store.collection("a").await.unwrap();
563        assert_eq!(collection.name(), "a");
564
565        // Numbers only
566        let collection = store.collection("123").await.unwrap();
567        assert_eq!(collection.name(), "123");
568
569        // Max length typical name
570        let long_name = "a".repeat(255);
571        let collection = store.collection(&long_name).await.unwrap();
572        assert_eq!(collection.name(), long_name);
573    }
574
575    #[tokio::test]
576    async fn test_store_new_with_passphrase() {
577        let temp_dir = tempdir().unwrap();
578        let store = Store::new(temp_dir.path(), Some("test_passphrase"))
579            .await
580            .unwrap();
581        // Should have created signing key
582        assert!(store.signing_key.is_some());
583    }
584
585    #[tokio::test]
586    async fn test_store_new_with_corrupted_keys() {
587        let temp_dir = tempdir().unwrap();
588        // First create a store with passphrase to generate keys
589        let _store = Store::new(temp_dir.path(), Some("test_passphrase"))
590            .await
591            .unwrap();
592
593        // Now corrupt the .keys collection by inserting a document with missing fields
594        let store2 = Store::new(temp_dir.path(), None).await.unwrap();
595        let keys_coll = store2.collection(".keys").await.unwrap();
596        // Insert corrupted document
597        let corrupted_data = serde_json::json!({
598            "salt": "invalid_salt",
599            // missing "encrypted"
600        });
601        keys_coll
602            .insert("signing_key", corrupted_data)
603            .await
604            .unwrap();
605
606        // Now try to create a new store with passphrase, should fail due to corruption
607        let result = Store::new(temp_dir.path(), Some("test_passphrase")).await;
608        assert!(result.is_err());
609    }
610
611    #[tokio::test]
612    async fn test_store_new_with_invalid_salt_hex() {
613        let temp_dir = tempdir().unwrap();
614        // First create a store with passphrase to generate keys
615        let _store = Store::new(temp_dir.path(), Some("test_passphrase"))
616            .await
617            .unwrap();
618
619        // Corrupt the salt to invalid hex
620        let store2 = Store::new(temp_dir.path(), None).await.unwrap();
621        let keys_coll = store2.collection(".keys").await.unwrap();
622        let doc = keys_coll.get("signing_key").await.unwrap().unwrap();
623        let mut data = doc.data().clone();
624        data["salt"] = serde_json::Value::String("invalid_hex".to_string());
625        keys_coll.insert("signing_key", data).await.unwrap();
626
627        // Try to load
628        let result = Store::new(temp_dir.path(), Some("test_passphrase")).await;
629        assert!(result.is_err());
630    }
631
632    #[tokio::test]
633    async fn test_store_new_with_invalid_encrypted_length() {
634        let temp_dir = tempdir().unwrap();
635        // First create a store with passphrase to generate keys
636        let _store = Store::new(temp_dir.path(), Some("test_passphrase"))
637            .await
638            .unwrap();
639
640        // Corrupt the encrypted to short
641        let store2 = Store::new(temp_dir.path(), None).await.unwrap();
642        let keys_coll = store2.collection(".keys").await.unwrap();
643        let doc = keys_coll.get("signing_key").await.unwrap().unwrap();
644        let mut data = doc.data().clone();
645        data["encrypted"] = serde_json::Value::String(hex::encode(&[0u8; 10])); // short
646        keys_coll.insert("signing_key", data).await.unwrap();
647
648        // Try to load
649        let result = Store::new(temp_dir.path(), Some("test_passphrase")).await;
650        assert!(result.is_err());
651    }
652}