/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use crate::utils::{cleanup::store_destroy, server::TestServerBuilder}; use ahash::AHashMap; use email::message::metadata::MessageMetadata; use registry::{ schema::{enums::CompressionAlgo, structs::Jmap}, types::duration::Duration, }; use services::task_manager::destroy_account::destroy_account_blobs; use store::{ BlobStore, Serialize, SerializeInfallible, write::{Archiver, BatchBuilder, BlobLink, BlobOp, ValueClass, now}, }; use types::{blob::BlobClass, blob_hash::BlobHash, collection::Collection, field::EmailField}; #[tokio::test] pub async fn blob_tests() { let test = TestServerBuilder::new("blob_tests") .await .with_object(Jmap { upload_quota: 1024, upload_ttl: Duration::from_millis(1000), ..Default::default() }) .await .build() .await; let store = test.server.core.storage.data.clone(); let blob_store = test.server.core.storage.blob.clone(); println!( "Testing blob store {} with data store {}...", std::env::var("BLOB_STORE").unwrap_or_else(|_| "default".to_string()), std::env::var("STORE").unwrap() ); // Test blob quota assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed); assert!(!test.server.blob_has_quota(0, 1024).await.unwrap().allowed); tokio::time::sleep(std::time::Duration::from_secs(1)).await; assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed); // Test and reset store test_store(blob_store.clone()).await; store_destroy(&store).await; // Blob hash exists let hash = BlobHash::generate(b"abc".as_slice()); assert!(!store.blob_exists(&hash).await.unwrap()); // Reserve blob let until = now() + 1; store .write( BatchBuilder::new() .with_account_id(0) .set( BlobOp::Link { to: BlobLink::Temporary { until }, hash: hash.clone(), }, 1024u32.serialize(), ) .build_all(), ) .await .unwrap(); // Uncommitted blob, should not exist assert!(!store.blob_exists(&hash).await.unwrap()); // Write blob to store blob_store .put_blob(hash.as_ref(), b"abc", CompressionAlgo::Lz4) .await .unwrap(); // Commit blob store .write( BatchBuilder::new() .set(BlobOp::Commit { hash: hash.clone() }, Vec::new()) .build_all(), ) .await .unwrap(); // Blob hash should now exist assert!(store.blob_exists(&hash).await.unwrap()); assert!( blob_store .get_blob(hash.as_ref(), 0..usize::MAX) .await .unwrap() .is_some() ); // AccountId 0 should be able to read blob assert!( store .blob_has_access( &hash, BlobClass::Reserved { account_id: 0, expires: until } ) .await .unwrap() ); // AccountId 1 should not be able to read blob assert!( !store .blob_has_access( &hash, BlobClass::Reserved { account_id: 1, expires: until } ) .await .unwrap() ); // Purge expired blobs tokio::time::sleep(std::time::Duration::from_secs(1)).await; store .purge_blobs_all_shards(blob_store.clone()) .await .unwrap(); // Blob hash should no longer exist assert!(!store.blob_exists(&hash).await.unwrap()); // AccountId 0 should not be able to read blob assert!( !store .blob_has_access( &hash, BlobClass::Reserved { account_id: 0, expires: until } ) .await .unwrap() ); // Blob should no longer be in store assert!( blob_store .get_blob(hash.as_ref(), 0..usize::MAX) .await .unwrap() .is_none() ); // Upload one linked blob to accountId 1, two linked blobs to accountId 0, and three unlinked (reserved) blobs to accountId 2 let expiry_times = AHashMap::from_iter([ (b"abc", now() - 10), (b"efg", now() + 10), (b"hij", now() + 10), ]); for (document_id, (blob, _)) in [ (b"123", vec![]), (b"456", vec![]), (b"789", vec![]), (b"abc", 5000u32.serialize()), (b"efg", 1000u32.serialize()), (b"hij", 2000u32.serialize()), ] .into_iter() .enumerate() { let hash = BlobHash::generate(blob.as_slice()); let mut batch = BatchBuilder::new(); batch .with_account_id(if document_id > 0 { 0 } else { 1 }) .with_collection(Collection::Email) .with_document(document_id as u32); if let Some(until) = expiry_times.get(blob) { batch.set( BlobOp::Link { hash: hash.clone(), to: BlobLink::Temporary { until: *until }, }, vec![], ); } else { batch .set( BlobOp::Link { hash: hash.clone(), to: BlobLink::Document, }, vec![], ) .set( ValueClass::Property(EmailField::Metadata.into()), Archiver::new(MessageMetadata { contents: Default::default(), rcvd_attach: Default::default(), blob_hash: hash.clone(), blob_body_offset: Default::default(), preview: Default::default(), raw_headers: Default::default(), }) .serialize() .unwrap(), ); }; batch.set(BlobOp::Commit { hash: hash.clone() }, vec![]); store.write(batch.build_all()).await.unwrap(); blob_store .put_blob(hash.as_ref(), blob.as_slice(), CompressionAlgo::Lz4) .await .unwrap(); } // Purge expired blobs and make sure nothing else is deleted store .purge_blobs_all_shards(blob_store.clone()) .await .unwrap(); for (pos, (blob, blob_class)) in [ ( b"abc", BlobClass::Reserved { account_id: 0, expires: expiry_times[&b"abc"], }, ), ( b"123", BlobClass::Linked { account_id: 1, collection: 0, document_id: 0, }, ), ( b"456", BlobClass::Linked { account_id: 0, collection: 0, document_id: 1, }, ), ( b"789", BlobClass::Linked { account_id: 0, collection: 0, document_id: 2, }, ), ( b"efg", BlobClass::Reserved { account_id: 0, expires: expiry_times[&b"efg"], }, ), ( b"hij", BlobClass::Reserved { account_id: 0, expires: expiry_times[&b"hij"], }, ), ] .into_iter() .enumerate() { let hash = BlobHash::generate(blob.as_slice()); let ct = pos == 0; assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct); assert!(store.blob_exists(&hash).await.unwrap() ^ ct); assert!( blob_store .get_blob(hash.as_ref(), 0..usize::MAX) .await .unwrap() .is_some() ^ ct ); } // AccountId 0 should not have access to accountId 1's blobs assert!( !store .blob_has_access( BlobHash::generate(b"123".as_slice()), BlobClass::Linked { account_id: 0, collection: 0, document_id: 0, } ) .await .unwrap() ); // Unlink blob store .write( BatchBuilder::new() .with_account_id(0) .with_collection(Collection::Email) .with_document(2) .clear(BlobOp::Link { hash: BlobHash::generate(b"789".as_slice()), to: BlobLink::Document, }) .build_all(), ) .await .unwrap(); // Purge and make sure blob is deleted store .purge_blobs_all_shards(blob_store.clone()) .await .unwrap(); for (pos, (blob, blob_class)) in [ ( b"789", BlobClass::Linked { account_id: 0, collection: 0, document_id: 2, }, ), ( b"123", BlobClass::Linked { account_id: 1, collection: 0, document_id: 0, }, ), ( b"456", BlobClass::Linked { account_id: 0, collection: 0, document_id: 1, }, ), ( b"efg", BlobClass::Reserved { account_id: 0, expires: expiry_times[&b"efg"], }, ), ( b"hij", BlobClass::Reserved { account_id: 0, expires: expiry_times[&b"hij"], }, ), ] .into_iter() .enumerate() { let ct = pos == 0; let hash = BlobHash::generate(blob.as_slice()); assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct); assert!(store.blob_exists(&hash).await.unwrap() ^ ct); assert!( blob_store .get_blob(hash.as_ref(), 0..usize::MAX) .await .unwrap() .is_some() ^ ct ); } // Unlink all blobs from accountId 1 and purge destroy_account_blobs(&test.server, 1).await.unwrap(); store .purge_blobs_all_shards(blob_store.clone()) .await .unwrap(); // Make sure only accountId 0's blobs are left for (pos, (blob, blob_class)) in [ ( b"123", BlobClass::Linked { account_id: 1, collection: 0, document_id: 0, }, ), ( b"456", BlobClass::Linked { account_id: 0, collection: 0, document_id: 1, }, ), ( b"efg", BlobClass::Reserved { account_id: 0, expires: expiry_times[&b"efg"], }, ), ( b"hij", BlobClass::Reserved { account_id: 0, expires: expiry_times[&b"hij"], }, ), ] .into_iter() .enumerate() { let ct = pos == 0; let hash = BlobHash::generate(blob.as_slice()); assert!(store.blob_has_access(&hash, blob_class).await.unwrap() ^ ct); assert!(store.blob_exists(&hash).await.unwrap() ^ ct); assert!( blob_store .get_blob(hash.as_ref(), 0..usize::MAX) .await .unwrap() .is_some() ^ ct ); } test.temp_dir.delete(); } async fn test_store(store: BlobStore) { // Test small blob const DATA: &[u8] = b"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce erat nisl, dignissim a porttitor id, varius nec arcu. Sed mauris."; let hash = BlobHash::generate(DATA); store .put_blob(hash.as_slice(), DATA, CompressionAlgo::Lz4) .await .unwrap(); assert_eq!( String::from_utf8( store .get_blob(hash.as_slice(), 0..usize::MAX) .await .unwrap() .unwrap() ) .unwrap(), std::str::from_utf8(DATA).unwrap() ); assert_eq!( String::from_utf8( store .get_blob(hash.as_slice(), 11..57) .await .unwrap() .unwrap() ) .unwrap(), std::str::from_utf8(&DATA[11..57]).unwrap() ); assert!(store.delete_blob(hash.as_slice()).await.unwrap()); assert!( store .get_blob(hash.as_slice(), 0..usize::MAX) .await .unwrap() .is_none() ); // Test large blob let mut data = Vec::with_capacity(50 * 1024 * 1024); while data.len() < 50 * 1024 * 1024 { data.extend_from_slice(DATA); let marker = format!(" [{}] ", data.len()); data.extend_from_slice(marker.as_bytes()); } let hash = BlobHash::generate(&data); store .put_blob(hash.as_slice(), &data, CompressionAlgo::Lz4) .await .unwrap(); assert_eq!( String::from_utf8( store .get_blob(hash.as_slice(), 0..usize::MAX) .await .unwrap() .unwrap() ) .unwrap(), std::str::from_utf8(&data).unwrap() ); assert_eq!( String::from_utf8( store .get_blob(hash.as_slice(), 3000111..4000999) .await .unwrap() .unwrap() ) .unwrap(), std::str::from_utf8(&data[3000111..4000999]).unwrap() ); assert!(store.delete_blob(hash.as_slice()).await.unwrap()); assert!( store .get_blob(hash.as_slice(), 0..usize::MAX) .await .unwrap() .is_none() ); }