1pub(crate) mod s3_common;
10
11#[cfg(any(test, feature = "test-utils"))]
12pub mod test_utils;
13
14#[cfg(feature = "oauth-providers")]
15pub(crate) mod account_email;
16pub mod cloudkit;
17mod counting;
18#[cfg(feature = "oauth-providers")]
19pub mod dropbox;
20mod factory;
21#[cfg(feature = "oauth-providers")]
22pub mod google_drive;
23#[cfg(feature = "oauth-providers")]
24mod http;
25#[cfg(feature = "oauth-providers")]
26mod key_encoding;
27#[cfg(feature = "oauth-providers")]
28mod oauth_rest;
29#[cfg(feature = "oauth-providers")]
30pub mod oauth_session;
31#[cfg(feature = "oauth-providers")]
32pub mod onedrive;
33#[cfg(feature = "oauth-providers")]
34mod resumable;
35mod runtime;
36pub mod s3;
37pub mod setup;
38#[cfg(feature = "oauth-providers")]
39mod sharing;
40#[cfg(test)]
41mod test_server;
42
43pub use counting::CountingCloudHome;
44use coven_protocol::objects::{ObjectSlot, StorageBackendFailure};
45pub use factory::CloudHomeFactory;
46#[cfg(feature = "oauth-providers")]
47pub use factory::PreparedOAuthCloudHome;
48#[cfg(feature = "oauth-providers")]
49pub(crate) use google_drive::{folder_search_query, supports_all_drives};
50pub use runtime::CloudRuntimeError;
51#[cfg(feature = "oauth-providers")]
52pub use setup::SetupError;
53
54mod blob_body;
55mod exact_upload;
56#[cfg(any(test, feature = "test-utils"))]
57pub(crate) use blob_body::PROGRESS_CHUNK_SIZE;
58pub(crate) use blob_body::{combine_cleanup_failure, MultipartUpload};
59pub use blob_body::{no_download_progress, no_preparation_progress, no_progress};
60pub use blob_body::{
61 BlobBody, BoxPartSink, DownloadProgress, PartSink, PreparationProgress, UploadControl,
62 UploadProgress,
63};
64pub use exact_upload::{ExactUpload, ExactUploadSource};
65
66#[cfg(test)]
67pub(crate) async fn create_exact_bytes(
68 storage: &dyn ExactSlotStorage,
69 slot: &ObjectSlot,
70 bytes: &[u8],
71 progress: &UploadProgress,
72) -> Result<ExactCreateOutcome, CloudHomeError> {
73 let object = coven_protocol::objects::ExactObjectRef::new(
74 slot.clone(),
75 bytes.len() as u64,
76 coven_protocol::store_commit::ObjectHash::digest(bytes),
77 );
78 let upload = ExactUpload::from_bytes(&object, bytes).map_err(CloudHomeError::from)?;
79 storage
80 .create_at(&upload, &UploadControl::running(progress.clone()))
81 .await
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum ExactCreateOutcome {
86 Created,
87 AlreadyPresent,
88}
89
90use async_trait::async_trait;
91use bytes::{Bytes, BytesMut};
92use serde::{Deserialize, Serialize};
93use std::path::Path;
94use std::pin::Pin;
95
96use futures_util::Stream;
97
98use crate::local_file::PlaintextReader;
99use coven_keys::encryption::{SealedBlobSealer, DEFAULT_BLOB_CHUNK_SIZE};
100
101#[derive(Debug, thiserror::Error)]
103pub enum CloudHomeError {
104 #[error("not found: {0}")]
105 NotFound(String),
106 #[error("already exists: {0}")]
107 AlreadyExists(String),
108 #[error("exact slot contains different bytes: {0}")]
109 SlotCollision(String),
110 #[error("configuration error: {0}")]
115 Configuration(String),
116 #[error("transport error: {0}")]
119 Transport(String),
120 #[error("cloud backend {kind:?} failure while {operation}: {source}")]
121 Backend {
122 kind: StorageBackendFailure,
123 operation: String,
124 #[source]
125 source: Box<dyn std::error::Error + Send + Sync>,
126 },
127 #[error("{operation}; cleanup failed: {cleanup}")]
128 CleanupFailed {
129 #[source]
130 operation: Box<CloudHomeError>,
131 cleanup: Box<CloudHomeError>,
132 },
133 #[error("{operation}; exact response settlement failed: {settlement}")]
134 UnresolvedOutcome {
135 #[source]
136 operation: Box<CloudHomeError>,
137 settlement: Box<CloudHomeError>,
138 },
139 #[error("I/O error: {0}")]
140 Io(#[from] std::io::Error),
141 #[error("local file error: {0}")]
142 Local(#[from] coven_foundation::atomic_file::FileError),
143 #[error("blob source failed: {0}")]
144 BlobSource(#[source] coven_protocol::objects::StorageError),
145 #[error("storage protocol failed: {0}")]
146 Protocol(#[source] coven_protocol::objects::StorageError),
147 #[error("blob source content is invalid: {0}")]
148 InvalidBlobSource(String),
149}
150
151pub use coven_protocol::objects::ExactObjectVersion as CloudObjectVersion;
152
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub struct CloudVersionedObject {
155 pub bytes: Vec<u8>,
156 pub version: CloudObjectVersion,
157}
158
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub enum ConditionalWriteOutcome {
161 Replaced(CloudObjectVersion),
162 VersionChanged,
163}
164
165pub type CloudObjectStream =
166 Pin<Box<dyn Stream<Item = Result<Bytes, CloudHomeError>> + Send + 'static>>;
167
168#[derive(Debug, thiserror::Error)]
169pub enum CloudFileReadError {
170 #[error(transparent)]
171 Source(#[from] CloudHomeError),
172 #[error("{source}; local cleanup failed: {cleanup}")]
173 SourceCleanup {
174 #[source]
175 source: CloudHomeError,
176 cleanup: coven_foundation::atomic_file::FileError,
177 },
178 #[error("local destination failed: {0}")]
179 Local(coven_foundation::atomic_file::FileError),
180}
181
182pub async fn write_cloud_object_stream(
183 destination: &Path,
184 stream: CloudObjectStream,
185 progress: DownloadProgress,
186) -> Result<u64, CloudFileReadError> {
187 use futures_util::StreamExt as _;
188 use std::sync::atomic::{AtomicU64, Ordering};
189
190 let received = std::sync::Arc::new(AtomicU64::new(0));
191 let stream_received = std::sync::Arc::clone(&received);
192 let stream_progress = std::sync::Arc::clone(&progress);
193 let stream: CloudObjectStream = Box::pin(stream.map(move |item| {
194 if let Ok(bytes) = &item {
195 let done = stream_received.fetch_add(bytes.len() as u64, Ordering::SeqCst)
196 + bytes.len() as u64;
197 stream_progress(done);
198 }
199 item
200 }));
201 let staged = coven_foundation::local_file::AtomicStagedFile::create(destination)
202 .await
203 .map_err(CloudFileReadError::Local)?;
204 let (staged, written) =
205 staged
206 .write_byte_stream(stream)
207 .await
208 .map_err(|error| match error {
209 coven_foundation::local_file::ByteStreamWriteError::Source(error) => {
210 CloudFileReadError::Source(error)
211 }
212 coven_foundation::local_file::ByteStreamWriteError::SourceCleanup {
213 source,
214 cleanup,
215 } => CloudFileReadError::SourceCleanup { source, cleanup },
216 coven_foundation::local_file::ByteStreamWriteError::Local(error) => {
217 CloudFileReadError::Local(error)
218 }
219 })?;
220 staged.commit().await.map_err(CloudFileReadError::Local)?;
221 Ok(written)
222}
223
224impl CloudHomeError {
225 pub fn backend(
226 kind: StorageBackendFailure,
227 operation: impl Into<String>,
228 source: impl std::error::Error + Send + Sync + 'static,
229 ) -> Self {
230 Self::Backend {
231 kind,
232 operation: operation.into(),
233 source: Box::new(source),
234 }
235 }
236
237 pub fn configuration(
238 operation: impl Into<String>,
239 source: impl std::error::Error + Send + Sync + 'static,
240 ) -> Self {
241 Self::backend(StorageBackendFailure::Configuration, operation, source)
242 }
243
244 pub fn transport(
245 operation: impl Into<String>,
246 source: impl std::error::Error + Send + Sync + 'static,
247 ) -> Self {
248 Self::backend(StorageBackendFailure::Transport, operation, source)
249 }
250
251 pub fn is_retryable(&self) -> bool {
257 match self {
258 CloudHomeError::Transport(_)
259 | CloudHomeError::Backend {
260 kind: StorageBackendFailure::Transport,
261 ..
262 }
263 | CloudHomeError::Io(_)
264 | CloudHomeError::Local(_) => true,
265 CloudHomeError::BlobSource(error) | CloudHomeError::Protocol(error) => {
266 error.is_transport()
267 }
268 CloudHomeError::CleanupFailed { operation, .. }
269 | CloudHomeError::UnresolvedOutcome { operation, .. } => operation.is_retryable(),
270 CloudHomeError::NotFound(_)
271 | CloudHomeError::AlreadyExists(_)
272 | CloudHomeError::SlotCollision(_)
273 | CloudHomeError::Configuration(_)
274 | CloudHomeError::Backend { .. }
275 | CloudHomeError::InvalidBlobSource(_) => false,
276 }
277 }
278
279 pub fn backend_failure(&self) -> Option<StorageBackendFailure> {
280 match self {
281 Self::Backend { kind, .. } => Some(*kind),
282 Self::Transport(_) => Some(StorageBackendFailure::Transport),
283 Self::Configuration(_) => Some(StorageBackendFailure::Configuration),
284 Self::CleanupFailed { operation, .. } | Self::UnresolvedOutcome { operation, .. } => {
285 operation.backend_failure()
286 }
287 Self::BlobSource(error) | Self::Protocol(error) => error.backend_failure(),
288 _ => None,
289 }
290 }
291
292 pub fn cleanup_causes(&self) -> Option<(&CloudHomeError, &CloudHomeError)> {
293 match self {
294 Self::CleanupFailed { operation, cleanup } => Some((operation, cleanup)),
295 _ => None,
296 }
297 }
298}
299
300#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
309#[serde(tag = "t", deny_unknown_fields)]
310pub enum CloudHomeJoinInfo {
311 #[serde(rename = "s3")]
312 S3 {
313 bucket: String,
314 region: String,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 endpoint: Option<String>,
317 access_key: String,
318 secret_key: String,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 key_prefix: Option<String>,
321 },
322 #[serde(rename = "gd")]
323 GoogleDrive { folder_id: String },
324 #[serde(rename = "db")]
328 Dropbox { folder_path: String },
329 #[serde(rename = "od")]
330 OneDrive { drive_id: String, folder_id: String },
331 #[serde(rename = "ck")]
332 CloudKit,
333 #[serde(rename = "cks")]
334 CloudKitShare {
335 share_url: String,
336 owner_name: String,
337 zone_name: String,
338 },
339}
340
341impl std::fmt::Debug for CloudHomeJoinInfo {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 match self {
344 CloudHomeJoinInfo::S3 {
345 bucket,
346 region,
347 endpoint,
348 access_key,
349 secret_key: _,
350 key_prefix,
351 } => f
352 .debug_struct("S3")
353 .field("bucket", bucket)
354 .field("region", region)
355 .field("endpoint", endpoint)
356 .field("access_key", access_key)
357 .field("secret_key", &"<redacted>")
358 .field("key_prefix", key_prefix)
359 .finish(),
360 CloudHomeJoinInfo::GoogleDrive { folder_id } => f
361 .debug_struct("GoogleDrive")
362 .field("folder_id", folder_id)
363 .finish(),
364 CloudHomeJoinInfo::Dropbox { folder_path } => f
365 .debug_struct("Dropbox")
366 .field("folder_path", folder_path)
367 .finish(),
368 CloudHomeJoinInfo::OneDrive {
369 drive_id,
370 folder_id,
371 } => f
372 .debug_struct("OneDrive")
373 .field("drive_id", drive_id)
374 .field("folder_id", folder_id)
375 .finish(),
376 CloudHomeJoinInfo::CloudKit => f.write_str("CloudKit"),
377 CloudHomeJoinInfo::CloudKitShare {
378 share_url,
379 owner_name,
380 zone_name,
381 } => f
382 .debug_struct("CloudKitShare")
383 .field("share_url", share_url)
384 .field("owner_name", owner_name)
385 .field("zone_name", zone_name)
386 .finish(),
387 }
388 }
389}
390
391impl CloudHomeJoinInfo {
392 pub fn cloud_provider(&self) -> coven_foundation::config::CloudProvider {
393 use coven_foundation::config::CloudProvider;
394 match self {
395 CloudHomeJoinInfo::S3 { .. } => CloudProvider::S3,
396 CloudHomeJoinInfo::GoogleDrive { .. } => CloudProvider::GoogleDrive,
397 CloudHomeJoinInfo::Dropbox { .. } => CloudProvider::Dropbox,
398 CloudHomeJoinInfo::OneDrive { .. } => CloudProvider::OneDrive,
399 CloudHomeJoinInfo::CloudKit | CloudHomeJoinInfo::CloudKitShare { .. } => {
400 CloudProvider::CloudKit
401 }
402 }
403 }
404}
405
406#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
407#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
408pub enum CloudAccessState {
409 Present {
410 member_pubkey: String,
411 provider_account_email: Option<String>,
412 },
413 Absent {
414 member_pubkey: String,
415 provider_account_email: Option<String>,
416 },
417}
418
419#[derive(Clone, Debug, PartialEq, Eq)]
420pub enum CloudAccessOutcome {
421 Present(CloudHomeJoinInfo),
422 Absent(RevokeOutcome),
423}
424
425#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434pub enum RevokeOutcome {
435 Revoked,
436 Unsupported,
437}
438
439impl CloudAccessState {
440 fn provider_account_email(&self) -> Option<&str> {
441 match self {
442 Self::Present {
443 provider_account_email,
444 ..
445 }
446 | Self::Absent {
447 provider_account_email,
448 ..
449 } => provider_account_email.as_deref(),
450 }
451 }
452
453 pub fn require_provider_email(&self, provider: &str) -> Result<&str, CloudHomeError> {
454 require_provider_email(provider, self.provider_account_email())
455 }
456}
457
458fn require_provider_email<'a>(
459 provider: &str,
460 email: Option<&'a str>,
461) -> Result<&'a str, CloudHomeError> {
462 match email {
463 Some(email) if !email.is_empty() => Ok(email),
464 _ => Err(CloudHomeError::Configuration(format!(
465 "{provider} sharing requires the invitee's provider account email"
466 ))),
467 }
468}
469
470pub(crate) fn range_header(start: u64, end: u64) -> String {
475 format!("bytes={start}-{}", end.saturating_sub(1))
476}
477
478pub(crate) fn logical_slots(keys: Vec<String>) -> Result<Vec<ObjectSlot>, CloudHomeError> {
481 keys.into_iter()
482 .map(|key| ObjectSlot::logical(key).map_err(CloudHomeError::from))
483 .collect()
484}
485
486#[async_trait]
491pub trait ExactSlotStorage: Send + Sync {
492 async fn provider_binding(
493 &self,
494 ) -> Result<coven_protocol::objects::ResolvedProviderBinding, CloudHomeError>;
495
496 async fn cross_principal_evidence(
497 &self,
498 ) -> Result<coven_protocol::provider::CrossPrincipalProviderEvidence, CloudHomeError> {
499 use coven_protocol::objects::{GoogleDriveCorpus, StoreProviderBinding};
500 use coven_protocol::provider::CrossPrincipalProviderEvidence;
501
502 match self.provider_binding().await?.store {
503 StoreProviderBinding::GoogleDrive {
504 corpus: GoogleDriveCorpus::SharedDrive { .. },
505 } => Ok(CrossPrincipalProviderEvidence::GoogleSharedDrive),
506 StoreProviderBinding::Dropbox { .. } => {
507 Ok(CrossPrincipalProviderEvidence::DropboxSharedNamespace)
508 }
509 StoreProviderBinding::OneDrive { .. } => {
510 Ok(CrossPrincipalProviderEvidence::OneDriveSharedFolder)
511 }
512 StoreProviderBinding::CloudKit { .. } => Err(CloudHomeError::Configuration(
513 "CloudKit exact-slot adapter did not supply accepted-share evidence".to_string(),
514 )),
515 StoreProviderBinding::GoogleDrive { .. } => Err(CloudHomeError::Configuration(
516 "Google Drive cross-principal access requires a shared drive".to_string(),
517 )),
518 StoreProviderBinding::S3 { .. } => Err(CloudHomeError::Configuration(
519 "S3 has no cross-principal provider evidence".to_string(),
520 )),
521 }
522 }
523
524 async fn allocate_slot(&self, logical_key: &str) -> Result<ObjectSlot, CloudHomeError> {
528 ObjectSlot::logical(logical_key.to_string()).map_err(CloudHomeError::from)
529 }
530
531 async fn list_slots(&self, prefix: &str) -> Result<Vec<ObjectSlot>, CloudHomeError>;
540
541 async fn create_at(
542 &self,
543 upload: &ExactUpload<'_>,
544 control: &UploadControl,
545 ) -> Result<ExactCreateOutcome, CloudHomeError>;
546
547 async fn create_versioned_at(
550 &self,
551 upload: &ExactUpload<'_>,
552 control: &UploadControl,
553 ) -> Result<ExactCreateOutcome, CloudHomeError> {
554 self.create_at(upload, control).await
555 }
556
557 async fn read_at(&self, slot: &ObjectSlot) -> Result<Vec<u8>, CloudHomeError>;
558
559 async fn read_versioned_at(
563 &self,
564 slot: &ObjectSlot,
565 ) -> Result<CloudVersionedObject, CloudHomeError>;
566
567 async fn replace_at_if_version(
571 &self,
572 slot: &ObjectSlot,
573 expected: &CloudObjectVersion,
574 bytes: Vec<u8>,
575 ) -> Result<ConditionalWriteOutcome, CloudHomeError>;
576
577 async fn delete_versioned_at(&self, slot: &ObjectSlot) -> Result<(), CloudHomeError> {
580 self.delete_at(slot).await
581 }
582
583 async fn observe_at(
584 &self,
585 slot: &ObjectSlot,
586 ) -> Result<Option<coven_protocol::objects::ExactObjectRef>, CloudHomeError> {
587 match self.read_at(slot).await {
588 Ok(bytes) => Ok(Some(coven_protocol::objects::ExactObjectRef::new(
589 slot.clone(),
590 bytes.len() as u64,
591 coven_protocol::store_commit::ObjectHash::digest(&bytes),
592 ))),
593 Err(CloudHomeError::NotFound(_)) => Ok(None),
594 Err(error) => Err(error),
595 }
596 }
597
598 async fn read_range_at(
599 &self,
600 slot: &ObjectSlot,
601 start: u64,
602 end: u64,
603 ) -> Result<Vec<u8>, CloudHomeError>;
604
605 async fn read_at_to_file(
606 &self,
607 slot: &ObjectSlot,
608 destination: &Path,
609 progress: DownloadProgress,
610 ) -> Result<(), CloudFileReadError>;
611
612 async fn delete_at(&self, slot: &ObjectSlot) -> Result<(), CloudHomeError>;
613
614 async fn delete_and_verify_absent(&self, slot: &ObjectSlot) -> Result<(), CloudHomeError> {
615 match self.read_at(slot).await {
616 Err(CloudHomeError::NotFound(_)) => Ok(()),
617 Ok(_) => {
618 self.delete_at(slot).await?;
619 match self.read_at(slot).await {
620 Err(CloudHomeError::NotFound(_)) => Ok(()),
621 Ok(_) => Err(CloudHomeError::Configuration(format!(
622 "exact-slot adapter left {} present after deletion",
623 slot.logical_key()
624 ))),
625 Err(error) => Err(error),
626 }
627 }
628 Err(error) => Err(error),
629 }
630 }
631}
632
633#[async_trait]
634pub trait CloudHome: Send + Sync {
635 async fn probe(&self) -> Result<(), CloudHomeError> {
642 self.list("__coven_probe__").await.map(drop)
643 }
644
645 async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError>;
649
650 async fn open_multipart<'a>(
653 &'a self,
654 key: &str,
655 total_len: u64,
656 ) -> Result<BoxPartSink<'a>, CloudHomeError>;
657
658 fn multipart_threshold(&self) -> u64;
661
662 fn provider_requests(
671 &self,
672 ) -> Option<std::sync::Arc<dyn coven_foundation::stage_timing::ProviderRequests>> {
673 None
674 }
675
676 async fn write(
680 &self,
681 key: &str,
682 body: BlobBody,
683 progress: &UploadProgress,
684 ) -> Result<(), CloudHomeError> {
685 if body.len() <= self.multipart_threshold() {
686 let data = body.collect().await?;
687 let n = data.len() as u64;
688 self.put_object(key, data).await?;
689 progress(n);
690 return Ok(());
691 }
692 let sink = self.open_multipart(key, body.len()).await?;
693 let control = UploadControl::running(progress.clone());
694 MultipartUpload::new(key, body, sink, &control).run().await
695 }
696
697 async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError>;
699
700 async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError>;
702
703 async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError>;
705
706 async fn delete(&self, key: &str) -> Result<(), CloudHomeError>;
708
709 async fn exists(&self, key: &str) -> Result<bool, CloudHomeError>;
711
712 async fn set_access(
719 &self,
720 desired: CloudAccessState,
721 ) -> Result<CloudAccessOutcome, CloudHomeError>;
722}
723
724pub trait ExactCloudHome: CloudHome + ExactSlotStorage {}
728
729impl<T> ExactCloudHome for T where T: CloudHome + ExactSlotStorage {}
730
731#[cfg(test)]
732mod counting_tests;
733
734#[cfg(test)]
735mod object_slot_tests;
736
737#[cfg(test)]
738mod join_info_tests;
739
740#[cfg(test)]
741mod retryable_tests;
742
743#[cfg(test)]
744mod streaming_tests;
745
746impl From<CloudHomeError> for coven_protocol::objects::StorageError {
747 fn from(e: CloudHomeError) -> Self {
748 match e {
749 CloudHomeError::NotFound(key) => coven_protocol::objects::StorageError::NotFound(key),
750 CloudHomeError::AlreadyExists(key) => {
751 coven_protocol::objects::StorageError::AlreadyExists(key)
752 }
753 CloudHomeError::SlotCollision(key) => {
754 coven_protocol::objects::StorageError::SlotCollision(key)
755 }
756 CloudHomeError::Configuration(msg) => {
757 coven_protocol::objects::StorageError::Configuration(msg)
758 }
759 CloudHomeError::Backend {
760 kind,
761 operation,
762 source,
763 } => coven_protocol::objects::StorageError::Backend {
764 kind,
765 operation,
766 source,
767 },
768 error @ CloudHomeError::Transport(_) => coven_protocol::objects::StorageError::backend(
769 StorageBackendFailure::Transport,
770 "access cloud storage",
771 error,
772 ),
773 CloudHomeError::CleanupFailed { operation, cleanup } => {
774 coven_protocol::objects::StorageError::CleanupFailed {
775 operation: Box::new(Self::from(*operation)),
776 cleanup: Box::new(Self::from(*cleanup)),
777 }
778 }
779 CloudHomeError::UnresolvedOutcome {
780 operation,
781 settlement,
782 } => coven_protocol::objects::StorageError::UnresolvedOutcome {
783 operation: Box::new(Self::from(*operation)),
784 settlement: Box::new(Self::from(*settlement)),
785 },
786 CloudHomeError::Io(io_err) => coven_protocol::objects::StorageError::Io(io_err),
787 CloudHomeError::Local(error) => {
788 coven_protocol::objects::StorageError::LocalFilesystem(error)
789 }
790 CloudHomeError::BlobSource(error) => error,
791 CloudHomeError::Protocol(error) => error,
792 CloudHomeError::InvalidBlobSource(message) => {
793 coven_protocol::objects::StorageError::InvalidContent(message)
794 }
795 }
796 }
797}
798
799impl From<coven_protocol::objects::StorageError> for CloudHomeError {
803 fn from(error: coven_protocol::objects::StorageError) -> Self {
804 CloudHomeError::Protocol(error)
805 }
806}