Skip to main content

coven_storage/cloud/
mod.rs

1//! CloudHome: low-level cloud storage abstraction.
2//!
3//! Each backend (S3, R2, B2, etc.) implements `CloudHome` -- 8 methods for
4//! raw bytes in/out. No encryption, no path layout knowledge, no sync
5//! semantics. Higher-level concerns live in `CloudSyncConnection` which wraps any
6//! `dyn CloudHome` and applies the path layout and at-rest protection.
7
8// Pure helpers that S3-compatible backends share.
9pub(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/// Errors from raw cloud storage operations.
102#[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    /// The cloud home is misconfigured or its credentials are missing or invalid:
111    /// a bucket/folder/drive that isn't set, credentials absent from the keyring, a
112    /// provider unsupported by this build, OAuth that needs re-authorization. The
113    /// user must fix the configuration; retrying the same operation cannot succeed.
114    #[error("configuration error: {0}")]
115    Configuration(String),
116    /// The cloud backend or the network to it failed: a request error, a non-2xx
117    /// status, a malformed response. Transient — a later attempt may succeed.
118    #[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    /// Whether the failure is transient — worth retrying the operation unchanged —
252    /// or a fault that will not resolve until the missing object appears or the user
253    /// fixes the configuration. A transport or local-I/O failure is transient
254    /// (`true`); a missing object, a misconfiguration, or absent/invalid credentials
255    /// are not (`false`).
256    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/// Information needed to join a cloud home from another device.
301///
302/// The compact tagged shape (short `t` tags) is shared by recipient-sealed
303/// membership admissions and restore codes, so both carry one exact provider
304/// shape.
305///
306/// `Debug` is hand-written so the S3 `secret_key` prints as `<redacted>` —
307/// `{:?}` in an error path cannot leak the storage credential.
308#[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    /// `folder_path` matches `CloudHomeConfig.dropbox_folder_path`, whose
325    /// `dropbox_` is the flat-config provider prefix (like `s3_bucket`) — one
326    /// name for this value everywhere it's carried.
327    #[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/// Whether a backend actually withdrew a removed member's storage credential.
426///
427/// Consumer clouds unshare the folder and report [`RevokeOutcome::Revoked`].
428/// Shared-credential backends (S3) hand out one static bucket key that cannot be
429/// withdrawn from a single member and report [`RevokeOutcome::Unsupported`].
430/// Removal proceeds either way: revoking chain membership and rotating the
431/// store key — not withdrawing the credential — is what protects post-removal
432/// content, so `Unsupported` is a truthful outcome, not a failure to paper over.
433#[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
470/// The HTTP `Range` header value for a ranged GET. `start` is inclusive and
471/// `end` is exclusive (the `CloudHome` contract); the header is inclusive on
472/// both ends, so the upper bound is `end - 1`. The one definition every backend
473/// — both S3 transports and the OAuth REST backends — uses.
474pub(crate) fn range_header(start: u64, end: u64) -> String {
475    format!("bytes={start}-{}", end.saturating_sub(1))
476}
477
478/// [`ExactSlotStorage::list_slots`] for a provider that addresses objects by
479/// their key, where the listed key is the whole locator.
480pub(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/// Low-level cloud storage. Implementations handle a single store.
487///
488/// All methods deal in raw bytes. No encryption or path layout logic.
489///
490#[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    /// Name the slot that holds `logical_key`. A provider that addresses objects
525    /// by the key itself allocates nothing; one that mints its own object id
526    /// overrides this and returns an opaque locator.
527    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    /// Name every slot this home holds whose logical key starts with `prefix`.
532    ///
533    /// The slot-shaped counterpart of [`CloudHome::list`], and the read side of
534    /// [`allocate_slot`](ExactSlotStorage::allocate_slot): a provider that
535    /// addresses objects by their key derives each slot from the listed key,
536    /// and one that mints its own object ids reports the ids it listed.
537    /// Callers get slots rather than keys because [`read_at`](Self::read_at) is
538    /// what they will do next, and that takes a slot.
539    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    /// Create one bounded mutable record without an immutable-object wrapper.
548    /// The returned object revision is obtained through [`Self::read_versioned_at`].
549    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    /// Read one mutable record and the provider revision that a conditional
560    /// replacement must present. The token is meaningful only to this provider
561    /// and this exact slot.
562    async fn read_versioned_at(
563        &self,
564        slot: &ObjectSlot,
565    ) -> Result<CloudVersionedObject, CloudHomeError>;
566
567    /// Replace one mutable record only while the provider still reports
568    /// `expected`. A changed revision is an ordinary competing-writer outcome;
569    /// an unavailable or ambiguous provider result is an error.
570    async fn replace_at_if_version(
571        &self,
572        slot: &ObjectSlot,
573        expected: &CloudObjectVersion,
574        bytes: Vec<u8>,
575    ) -> Result<ConditionalWriteOutcome, CloudHomeError>;
576
577    /// Delete one direct versioned record. This is used only for capability
578    /// probe cleanup; Store publication never deletes its current record.
579    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    /// Verify the backend is reachable with the configured credentials.
636    /// Setup flows call this *before* persisting credentials, so a typo or
637    /// missing bucket fails fast at setup time instead of via a delayed
638    /// reconnect banner. Default implementation issues a no-op list against
639    /// a sentinel prefix — backends override when a provider-specific operation
640    /// verifies the capabilities sync requires.
641    async fn probe(&self) -> Result<(), CloudHomeError> {
642        self.list("__coven_probe__").await.map(drop)
643    }
644
645    /// One bounded single-request upload, creating or overwriting `key`. Used only
646    /// for blobs at or below [`multipart_threshold`](CloudHome::multipart_threshold);
647    /// large blobs stream through [`open_multipart`](CloudHome::open_multipart).
648    async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError>;
649
650    /// Open a streaming multipart/resumable upload for `total_len` bytes, returning
651    /// the [`PartSink`] the driver pumps ordered parts into.
652    async fn open_multipart<'a>(
653        &'a self,
654        key: &str,
655        total_len: u64,
656    ) -> Result<BoxPartSink<'a>, CloudHomeError>;
657
658    /// Blobs at or below this size go via [`put_object`](CloudHome::put_object);
659    /// larger ones stream via [`open_multipart`](CloudHome::open_multipart).
660    fn multipart_threshold(&self) -> u64;
661
662    /// The running total of provider operations issued through this home, for
663    /// a run's stage timings to report each stage's count beside its wall time.
664    ///
665    /// `None` from a home nobody wrapped for counting — every provider's own
666    /// implementation, and the in-memory homes tests run against. A run told
667    /// `None` reports its times alone, because a column of zeroes would claim a
668    /// measurement nobody took. [`CountingCloudHome`] is what answers `Some`,
669    /// and the factory wraps every home it builds in one.
670    fn provider_requests(
671        &self,
672    ) -> Option<std::sync::Arc<dyn coven_foundation::stage_timing::ProviderRequests>> {
673        None
674    }
675
676    /// Write a sized [`BlobBody`] to `key`. Not overridden — the central
677    /// `write_blob` driver picks single-request vs multipart and pumps the
678    /// parts, reporting cumulative bytes through `progress` for the per-file bar.
679    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    /// Read the full contents of a key.
698    async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError>;
699
700    /// Read a byte range from a key. `start` is inclusive, `end` is exclusive.
701    async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError>;
702
703    /// List all keys under a prefix.
704    async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError>;
705
706    /// Delete a key. Not an error if the key does not exist.
707    async fn delete(&self, key: &str) -> Result<(), CloudHomeError>;
708
709    /// Check whether a key exists.
710    async fn exists(&self, key: &str) -> Result<bool, CloudHomeError>;
711
712    /// Set the provider's access for one stable member principal to the absolute
713    /// desired state. Implementations read the authoritative permission state,
714    /// create/update/delete as required, then read it back and verify the desired
715    /// state. Repeating a request after an unknown outcome is therefore
716    /// idempotent. `Present` returns connection information; `Absent` returns
717    /// whether this provider supports withdrawing one member's credential.
718    async fn set_access(
719        &self,
720        desired: CloudAccessState,
721    ) -> Result<CloudAccessOutcome, CloudHomeError>;
722}
723
724/// A cloud home admitted to sync: raw object operations and exact immutable
725/// slots are one provider capability, so callers cannot open the home and then
726/// ask it to hand back a second provider object.
727pub 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
799/// Slot and reference validation lives on the protocol values and reports
800/// [`coven_protocol::objects::StorageError`]; provider code folds it into its
801/// own configuration vocabulary.
802impl From<coven_protocol::objects::StorageError> for CloudHomeError {
803    fn from(error: coven_protocol::objects::StorageError) -> Self {
804        CloudHomeError::Protocol(error)
805    }
806}