1use std::{path::PathBuf, sync::Arc};
2
3use serde_json::Value;
4use tokio::fs as tokio_fs;
5use tracing::{debug, error, trace, warn};
6
7use crate::{
8 validation::{is_reserved_name, is_valid_document_id_chars},
9 Document,
10 Result,
11 SentinelError,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
51#[allow(
52 clippy::field_scoped_visibility_modifiers,
53 reason = "fields need to be pub(crate) for internal access"
54)]
55pub struct Collection {
56 pub(crate) path: PathBuf,
58 pub(crate) signing_key: Option<Arc<sentinel_crypto::SigningKey>>,
60}
61
62impl Collection {
63 pub fn name(&self) -> &str { self.path.file_name().unwrap().to_str().unwrap() }
65
66 pub async fn insert(&self, id: &str, data: Value) -> Result<()> {
104 trace!("Inserting document with id: {}", id);
105 validate_document_id(id)?;
106 let file_path = self.path.join(format!("{}.json", id));
107
108 #[allow(clippy::pattern_type_mismatch, reason = "false positive")]
109 let doc = if let Some(key) = &self.signing_key {
110 debug!("Creating signed document for id: {}", id);
111 Document::new(id.to_owned(), data, key)?
112 }
113 else {
114 debug!("Creating unsigned document for id: {}", id);
115 Document::new_without_signature(id.to_owned(), data)?
116 };
117 let json = serde_json::to_string_pretty(&doc).map_err(|e| {
118 error!("Failed to serialize document {} to JSON: {}", id, e);
119 e
120 })?;
121 tokio_fs::write(&file_path, json).await.map_err(|e| {
122 error!(
123 "Failed to write document {} to file {:?}: {}",
124 id, file_path, e
125 );
126 e
127 })?;
128 debug!("Document {} inserted successfully", id);
129 Ok(())
130 }
131
132 pub async fn get(&self, id: &str) -> Result<Option<Document>> {
173 trace!("Retrieving document with id: {}", id);
174 validate_document_id(id)?;
175 let file_path = self.path.join(format!("{}.json", id));
176 match tokio_fs::read_to_string(&file_path).await {
177 Ok(content) => {
178 debug!("Document {} found, parsing JSON", id);
179 let mut doc: Document = serde_json::from_str(&content).map_err(|e| {
180 error!("Failed to parse JSON for document {}: {}", id, e);
181 e
182 })?;
183 doc.id = id.to_owned();
185 trace!("Document {} retrieved successfully", id);
186 Ok(Some(doc))
187 },
188 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
189 debug!("Document {} not found", id);
190 Ok(None)
191 },
192 Err(e) => {
193 error!("IO error reading document {}: {}", id, e);
194 Err(SentinelError::Io {
195 source: e,
196 })
197 },
198 }
199 }
200
201 pub async fn update(&self, id: &str, data: Value) -> Result<()> {
239 self.insert(id, data).await
241 }
242
243 pub async fn delete(&self, id: &str) -> Result<()> {
287 trace!("Deleting document with id: {}", id);
288 validate_document_id(id)?;
289 let source_path = self.path.join(format!("{}.json", id));
290 let deleted_dir = self.path.join(".deleted");
291 let dest_path = deleted_dir.join(format!("{}.json", id));
292
293 match tokio_fs::metadata(&source_path).await {
295 Ok(_) => {
296 debug!("Document {} exists, moving to .deleted", id);
297 tokio_fs::create_dir_all(&deleted_dir).await.map_err(|e| {
299 error!(
300 "Failed to create .deleted directory {:?}: {}",
301 deleted_dir, e
302 );
303 e
304 })?;
305 tokio_fs::rename(&source_path, &dest_path)
307 .await
308 .map_err(|e| {
309 error!("Failed to move document {} to .deleted: {}", id, e);
310 e
311 })?;
312 debug!("Document {} soft deleted successfully", id);
313 Ok(())
314 },
315 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
316 debug!(
317 "Document {} not found, already deleted or never existed",
318 id
319 );
320 Ok(())
321 },
322 Err(e) => {
323 error!("IO error checking document {} existence: {}", id, e);
324 Err(SentinelError::Io {
325 source: e,
326 })
327 },
328 }
329 }
330
331 pub async fn list(&self) -> Result<Vec<String>> {
366 trace!("Listing documents in collection: {}", self.name());
367 let mut entries = tokio_fs::read_dir(&self.path).await.map_err(|e| {
368 error!("Failed to read collection directory {:?}: {}", self.path, e);
369 e
370 })?;
371 let mut ids = Vec::new();
372
373 while let Some(entry) = entries.next_entry().await? {
374 let path = entry.path();
375 if !entry.file_type().await?.is_dir() &&
376 let Some(extension) = path.extension() &&
377 extension == "json" &&
378 let Some(file_stem) = path.file_stem() &&
379 let Some(id) = file_stem.to_str()
380 {
381 ids.push(id.to_owned());
382 }
383 }
385
386 ids.sort();
388 debug!(
389 "Found {} documents in collection {}",
390 ids.len(),
391 self.name()
392 );
393 trace!("Documents listed successfully");
394 Ok(ids)
395 }
396
397 pub async fn bulk_insert(&self, documents: Vec<(&str, Value)>) -> Result<()> {
440 let count = documents.len();
441 trace!(
442 "Bulk inserting {} documents into collection {}",
443 count,
444 self.name()
445 );
446 for (id, data) in documents {
447 self.insert(id, data).await?;
448 }
449 debug!("Bulk insert of {} documents completed successfully", count);
450 Ok(())
451 }
452}
453
454pub(crate) fn validate_document_id(id: &str) -> Result<()> {
471 trace!("Validating document id: {}", id);
472 if id.is_empty() {
474 warn!("Document id is empty");
475 return Err(SentinelError::InvalidDocumentId {
476 id: id.to_owned(),
477 });
478 }
479
480 if !is_valid_document_id_chars(id) {
482 warn!("Document id contains invalid characters: {}", id);
483 return Err(SentinelError::InvalidDocumentId {
484 id: id.to_owned(),
485 });
486 }
487
488 if is_reserved_name(id) {
490 warn!("Document id is a reserved name: {}", id);
491 return Err(SentinelError::InvalidDocumentId {
492 id: id.to_owned(),
493 });
494 }
495
496 trace!("Document id '{}' is valid", id);
497 Ok(())
498}
499
500#[cfg(test)]
501mod tests {
502 use serde_json::json;
503 use tempfile::tempdir;
504
505 use super::*;
506 use crate::Store;
507
508 async fn setup_collection() -> (Collection, tempfile::TempDir) {
510 let temp_dir = tempdir().unwrap();
511 let store = Store::new(temp_dir.path(), None).await.unwrap();
512 let collection = store.collection("test_collection").await.unwrap();
513 (collection, temp_dir)
514 }
515
516 async fn setup_collection_with_signing_key() -> (Collection, tempfile::TempDir) {
518 let temp_dir = tempdir().unwrap();
519 let store = Store::new(temp_dir.path(), Some("test_passphrase"))
520 .await
521 .unwrap();
522 let collection = store.collection("test_collection").await.unwrap();
523 (collection, temp_dir)
524 }
525
526 #[tokio::test]
527 async fn test_insert_and_retrieve() {
528 let (collection, _temp_dir) = setup_collection().await;
529
530 let doc = json!({ "name": "Alice", "email": "alice@example.com" });
531 collection.insert("user-123", doc.clone()).await.unwrap();
532
533 let retrieved = collection.get("user-123").await.unwrap();
534 assert_eq!(*retrieved.unwrap().data(), doc);
535 }
536
537 #[tokio::test]
538 async fn test_insert_empty_document() {
539 let (collection, _temp_dir) = setup_collection().await;
540
541 let doc = json!({});
542 collection.insert("empty", doc.clone()).await.unwrap();
543
544 let retrieved = collection.get("empty").await.unwrap();
545 assert_eq!(*retrieved.unwrap().data(), doc);
546 }
547
548 #[tokio::test]
549 async fn test_insert_with_signing_key() {
550 let (collection, _temp_dir) = setup_collection_with_signing_key().await;
551
552 let doc = json!({ "name": "Alice", "signed": true });
553 collection.insert("signed_doc", doc.clone()).await.unwrap();
554
555 let retrieved = collection.get("signed_doc").await.unwrap().unwrap();
556 assert_eq!(*retrieved.data(), doc);
557 assert!(!retrieved.signature().is_empty());
559 }
560
561 #[tokio::test]
562 async fn test_insert_large_document() {
563 let (collection, _temp_dir) = setup_collection().await;
564
565 let large_data = json!({
566 "large_array": (0..1000).collect::<Vec<_>>(),
567 "nested": {
568 "deep": {
569 "value": "test"
570 }
571 }
572 });
573 collection
574 .insert("large", large_data.clone())
575 .await
576 .unwrap();
577
578 let retrieved = collection.get("large").await.unwrap();
579 assert_eq!(*retrieved.unwrap().data(), large_data);
580 }
581
582 #[tokio::test]
583 async fn test_insert_with_invalid_special_characters_in_id() {
584 let (collection, _temp_dir) = setup_collection().await;
585
586 let doc = json!({ "data": "test" });
587 let result = collection.insert("user_123-special!", doc.clone()).await;
588
589 assert!(result.is_err());
591 match result {
592 Err(SentinelError::InvalidDocumentId {
593 id,
594 }) => {
595 assert_eq!(id, "user_123-special!");
596 },
597 _ => panic!("Expected InvalidDocumentId error"),
598 }
599 }
600
601 #[tokio::test]
602 async fn test_insert_with_valid_document_ids() {
603 let (collection, _temp_dir) = setup_collection().await;
604
605 let valid_ids = vec![
607 "user-123",
608 "user_456",
609 "user123",
610 "123",
611 "a",
612 "user-123_test",
613 "user_123-test",
614 "CamelCaseID",
615 "lower_case_id",
616 "UPPER_CASE_ID",
617 ];
618
619 for id in valid_ids {
620 let doc = json!({ "data": "test" });
621 let result = collection.insert(id, doc).await;
622 assert!(
623 result.is_ok(),
624 "Expected ID '{}' to be valid but got error: {:?}",
625 id,
626 result
627 );
628 }
629 }
630
631 #[tokio::test]
632 async fn test_insert_with_various_invalid_document_ids() {
633 let (collection, _temp_dir) = setup_collection().await;
634
635 let invalid_ids = vec![
637 "user!123", "user@domain", "user#123", "user$123", "user%123", "user^123", "user&123", "user*123", "user(123)", "user.123", "user/123", "user\\123", "user:123", "user;123", "user<123", "user>123", "user?123", "user|123", "user\"123", "user'123", "", ];
659
660 for id in invalid_ids {
661 let doc = json!({ "data": "test" });
662 let result = collection.insert(id, doc).await;
663 assert!(
664 result.is_err(),
665 "Expected ID '{}' to be invalid but insertion succeeded",
666 id
667 );
668 match result {
669 Err(SentinelError::InvalidDocumentId {
670 ..
671 }) => {
672 },
674 _ => panic!("Expected InvalidDocumentId error for ID '{}'", id),
675 }
676 }
677 }
678
679 #[tokio::test]
680 async fn test_get_nonexistent() {
681 let (collection, _temp_dir) = setup_collection().await;
682
683 let retrieved = collection.get("nonexistent").await.unwrap();
684 assert!(retrieved.is_none());
685 }
686
687 #[tokio::test]
688 async fn test_update() {
689 let (collection, _temp_dir) = setup_collection().await;
690
691 let doc1 = json!({ "name": "Alice" });
692 collection.insert("user-123", doc1).await.unwrap();
693
694 let doc2 = json!({ "name": "Alice", "age": 30 });
695 collection.update("user-123", doc2.clone()).await.unwrap();
696
697 let retrieved = collection.get("user-123").await.unwrap();
698 assert_eq!(*retrieved.unwrap().data(), doc2);
699 }
700
701 #[tokio::test]
702 async fn test_update_nonexistent() {
703 let (collection, _temp_dir) = setup_collection().await;
704
705 let doc = json!({ "name": "Bob" });
706 collection.update("new-user", doc.clone()).await.unwrap();
707
708 let retrieved = collection.get("new-user").await.unwrap();
709 assert_eq!(*retrieved.unwrap().data(), doc);
710 }
711
712 #[tokio::test]
713 async fn test_update_with_invalid_id() {
714 let (collection, _temp_dir) = setup_collection().await;
715
716 let doc = json!({ "name": "Bob" });
717 let result = collection.update("user!invalid", doc).await;
718
719 assert!(result.is_err());
721 match result {
722 Err(SentinelError::InvalidDocumentId {
723 id,
724 }) => {
725 assert_eq!(id, "user!invalid");
726 },
727 _ => panic!("Expected InvalidDocumentId error"),
728 }
729 }
730
731 #[tokio::test]
732 async fn test_delete() {
733 let (collection, _temp_dir) = setup_collection().await;
734
735 let doc = json!({ "name": "Alice" });
736 collection.insert("user-123", doc).await.unwrap();
737
738 let retrieved = collection.get("user-123").await.unwrap();
739 assert!(retrieved.is_some());
740
741 collection.delete("user-123").await.unwrap();
742
743 let retrieved = collection.get("user-123").await.unwrap();
744 assert!(retrieved.is_none());
745
746 let deleted_path = collection.path.join(".deleted").join("user-123.json");
748 assert!(tokio_fs::try_exists(&deleted_path).await.unwrap());
749 }
750
751 #[tokio::test]
752 async fn test_delete_nonexistent() {
753 let (collection, _temp_dir) = setup_collection().await;
754
755 collection.delete("nonexistent").await.unwrap();
757 }
758
759 #[tokio::test]
760 async fn test_list_empty_collection() {
761 let (collection, _temp_dir) = setup_collection().await;
762
763 let ids = collection.list().await.unwrap();
764 assert!(ids.is_empty());
765 }
766
767 #[tokio::test]
768 async fn test_list_with_documents() {
769 let (collection, _temp_dir) = setup_collection().await;
770
771 collection
772 .insert("user-123", json!({"name": "Alice"}))
773 .await
774 .unwrap();
775 collection
776 .insert("user-456", json!({"name": "Bob"}))
777 .await
778 .unwrap();
779 collection
780 .insert("user-789", json!({"name": "Charlie"}))
781 .await
782 .unwrap();
783
784 let ids = collection.list().await.unwrap();
785 assert_eq!(ids.len(), 3);
786 assert_eq!(ids, vec!["user-123", "user-456", "user-789"]);
787 }
788
789 #[tokio::test]
790 async fn test_list_skips_deleted_documents() {
791 let (collection, _temp_dir) = setup_collection().await;
792
793 collection
794 .insert("user-123", json!({"name": "Alice"}))
795 .await
796 .unwrap();
797 collection
798 .insert("user-456", json!({"name": "Bob"}))
799 .await
800 .unwrap();
801 collection.delete("user-456").await.unwrap();
802
803 let ids = collection.list().await.unwrap();
804 assert_eq!(ids.len(), 1);
805 assert_eq!(ids, vec!["user-123"]);
806 }
807
808 #[tokio::test]
809 async fn test_bulk_insert() {
810 let (collection, _temp_dir) = setup_collection().await;
811
812 let documents = vec![
813 ("user-123", json!({"name": "Alice", "role": "admin"})),
814 ("user-456", json!({"name": "Bob", "role": "user"})),
815 ("user-789", json!({"name": "Charlie", "role": "user"})),
816 ];
817
818 collection.bulk_insert(documents).await.unwrap();
819
820 let ids = collection.list().await.unwrap();
821 assert_eq!(ids.len(), 3);
822 assert!(ids.contains(&"user-123".to_string()));
823 assert!(ids.contains(&"user-456".to_string()));
824 assert!(ids.contains(&"user-789".to_string()));
825
826 let alice = collection.get("user-123").await.unwrap().unwrap();
828 assert_eq!(alice.data()["name"], "Alice");
829 assert_eq!(alice.data()["role"], "admin");
830 }
831
832 #[tokio::test]
833 async fn test_bulk_insert_empty() {
834 let (collection, _temp_dir) = setup_collection().await;
835
836 collection.bulk_insert(vec![]).await.unwrap();
837
838 let ids = collection.list().await.unwrap();
839 assert!(ids.is_empty());
840 }
841
842 #[tokio::test]
843 async fn test_bulk_insert_with_invalid_id() {
844 let (collection, _temp_dir) = setup_collection().await;
845
846 let documents = vec![
847 ("user-123", json!({"name": "Alice"})),
848 ("user!invalid", json!({"name": "Bob"})),
849 ];
850
851 let result = collection.bulk_insert(documents).await;
852 assert!(result.is_err());
853
854 let ids = collection.list().await.unwrap();
856 assert_eq!(ids.len(), 1);
857 assert_eq!(ids[0], "user-123");
858 }
859
860 #[tokio::test]
861 async fn test_multiple_operations() {
862 let (collection, _temp_dir) = setup_collection().await;
863
864 collection
866 .insert("user1", json!({"name": "User1"}))
867 .await
868 .unwrap();
869 collection
870 .insert("user2", json!({"name": "User2"}))
871 .await
872 .unwrap();
873
874 let user1 = collection.get("user1").await.unwrap().unwrap();
876 let user2 = collection.get("user2").await.unwrap().unwrap();
877 assert_eq!(user1.data()["name"], "User1");
878 assert_eq!(user2.data()["name"], "User2");
879
880 collection
882 .update("user1", json!({"name": "Updated"}))
883 .await
884 .unwrap();
885 let updated = collection.get("user1").await.unwrap().unwrap();
886 assert_eq!(updated.data()["name"], "Updated");
887
888 collection.delete("user2").await.unwrap();
890 assert!(collection.get("user2").await.unwrap().is_none());
891 assert!(collection.get("user1").await.unwrap().is_some());
892 }
893
894 #[test]
895 fn test_validate_document_id_valid() {
896 assert!(validate_document_id("user-123").is_ok());
898 assert!(validate_document_id("user_456").is_ok());
899 assert!(validate_document_id("data-item").is_ok());
900 assert!(validate_document_id("test_collection_123").is_ok());
901 assert!(validate_document_id("file-txt").is_ok());
902 assert!(validate_document_id("a").is_ok());
903 assert!(validate_document_id("123").is_ok());
904 }
905
906 #[test]
907 fn test_validate_document_id_invalid_empty() {
908 assert!(validate_document_id("").is_err());
909 }
910
911 #[test]
912 fn test_validate_document_id_invalid_path_separators() {
913 assert!(validate_document_id("path/traversal").is_err());
914 assert!(validate_document_id("path\\traversal").is_err());
915 }
916
917 #[test]
918 fn test_validate_document_id_invalid_control_characters() {
919 assert!(validate_document_id("file\nname").is_err());
920 assert!(validate_document_id("file\x00name").is_err());
921 }
922
923 #[test]
924 fn test_validate_document_id_invalid_windows_reserved_characters() {
925 assert!(validate_document_id("file<name>").is_err());
926 assert!(validate_document_id("file>name").is_err());
927 assert!(validate_document_id("file:name").is_err());
928 assert!(validate_document_id("file\"name").is_err());
929 assert!(validate_document_id("file|name").is_err());
930 assert!(validate_document_id("file?name").is_err());
931 assert!(validate_document_id("file*name").is_err());
932 }
933
934 #[test]
935 fn test_validate_document_id_invalid_other_characters() {
936 assert!(validate_document_id("file name").is_err()); assert!(validate_document_id("file@name").is_err()); assert!(validate_document_id("file!name").is_err()); assert!(validate_document_id("filešname").is_err()); assert!(validate_document_id("fileĆ©name").is_err()); assert!(validate_document_id("file.name").is_err()); }
943
944 #[test]
945 fn test_validate_document_id_invalid_windows_reserved_names() {
946 assert!(validate_document_id("CON").is_err());
948 assert!(validate_document_id("con").is_err());
949 assert!(validate_document_id("Con").is_err());
950 assert!(validate_document_id("PRN").is_err());
951 assert!(validate_document_id("AUX").is_err());
952 assert!(validate_document_id("NUL").is_err());
953 assert!(validate_document_id("COM1").is_err());
954 assert!(validate_document_id("LPT9").is_err());
955
956 assert!(validate_document_id("CON.txt").is_err());
958 assert!(validate_document_id("prn.backup").is_err());
959 }
960
961 #[tokio::test]
962 async fn test_insert_invalid_document_id() {
963 let (collection, _temp_dir) = setup_collection().await;
964
965 let doc = json!({ "data": "test" });
966
967 assert!(collection.insert("", doc.clone()).await.is_err());
969
970 assert!(collection.insert("CON", doc.clone()).await.is_err());
972
973 assert!(collection.insert("file name", doc.clone()).await.is_err());
975 }
976
977 #[tokio::test]
978 async fn test_get_corrupted_json() {
979 let (collection, _temp_dir) = setup_collection().await;
980
981 let file_path = collection.path.join("corrupted.json");
983 tokio_fs::write(&file_path, "{ invalid json }")
984 .await
985 .unwrap();
986
987 let result = collection.get("corrupted").await;
988 assert!(result.is_err());
989 }
990
991 #[tokio::test]
992 async fn test_update_invalid_document_id() {
993 let (collection, _temp_dir) = setup_collection().await;
994
995 let doc = json!({ "data": "test" });
996
997 assert!(collection.update("", doc.clone()).await.is_err());
999
1000 assert!(collection.update("CON", doc.clone()).await.is_err());
1002 }
1003
1004 #[tokio::test]
1005 async fn test_delete_invalid_document_id() {
1006 let (collection, _temp_dir) = setup_collection().await;
1007
1008 assert!(collection.delete("").await.is_err());
1010
1011 assert!(collection.delete("CON").await.is_err());
1013 }
1014}