Skip to main content

coven_core/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 `CloudSyncStorage` 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 mod s3_common;
10
11#[cfg(any(test, feature = "test-utils"))]
12pub mod test_utils;
13
14use async_trait::async_trait;
15use bytes::{Bytes, BytesMut};
16use serde::{Deserialize, Deserializer, Serialize};
17use std::path::Path;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use futures_util::Stream;
22
23use crate::encryption::{ChunkSealer, CHUNK_SIZE};
24use crate::local_blob::PlaintextReader;
25
26/// Errors from raw cloud storage operations.
27#[derive(Debug, thiserror::Error)]
28pub enum CloudHomeError {
29    #[error("not found: {0}")]
30    NotFound(String),
31    #[error("already exists: {0}")]
32    AlreadyExists(String),
33    /// The cloud home is misconfigured or its credentials are missing or invalid:
34    /// a bucket/folder/drive that isn't set, credentials absent from the keyring, a
35    /// provider unsupported by this build, OAuth that needs re-authorization. The
36    /// user must fix the configuration; retrying the same operation cannot succeed.
37    #[error("configuration error: {0}")]
38    Configuration(String),
39    /// The cloud backend or the network to it failed: a request error, a non-2xx
40    /// status, a malformed response. Transient — a later attempt may succeed.
41    #[error("transport error: {0}")]
42    Transport(String),
43    #[error("{operation}; cleanup failed: {cleanup}")]
44    CleanupFailed {
45        #[source]
46        operation: Box<CloudHomeError>,
47        cleanup: Box<CloudHomeError>,
48    },
49    #[error("{operation}; exact response-loss readback failed: {readback}")]
50    UnresolvedOutcome {
51        #[source]
52        operation: Box<CloudHomeError>,
53        readback: Box<CloudHomeError>,
54    },
55    #[error("I/O error: {0}")]
56    Io(#[from] std::io::Error),
57}
58
59/// Provider-specific physical address for a caller-reserved immutable slot.
60#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
61#[serde(
62    tag = "kind",
63    content = "value",
64    rename_all = "snake_case",
65    deny_unknown_fields
66)]
67pub enum PhysicalObjectLocator {
68    LogicalKey,
69    Opaque(String),
70}
71
72/// Exact logical and physical location persisted before an immutable write.
73#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
74#[serde(deny_unknown_fields)]
75pub struct ObjectSlot {
76    logical_key: String,
77    physical: PhysicalObjectLocator,
78}
79
80impl<'de> Deserialize<'de> for ObjectSlot {
81    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82    where
83        D: Deserializer<'de>,
84    {
85        #[derive(Deserialize)]
86        #[serde(deny_unknown_fields)]
87        struct Fields {
88            logical_key: String,
89            physical: PhysicalObjectLocator,
90        }
91
92        let fields = Fields::deserialize(deserializer)?;
93        Self::new(fields.logical_key, fields.physical).map_err(serde::de::Error::custom)
94    }
95}
96
97impl ObjectSlot {
98    pub fn logical(logical_key: String) -> Result<Self, CloudHomeError> {
99        Self::new(logical_key, PhysicalObjectLocator::LogicalKey)
100    }
101
102    pub fn opaque(logical_key: String, provider_id: String) -> Result<Self, CloudHomeError> {
103        Self::new(logical_key, PhysicalObjectLocator::Opaque(provider_id))
104    }
105
106    fn new(logical_key: String, physical: PhysicalObjectLocator) -> Result<Self, CloudHomeError> {
107        let slot = Self {
108            logical_key,
109            physical,
110        };
111        slot.validate()?;
112        Ok(slot)
113    }
114
115    pub fn validate(&self) -> Result<(), CloudHomeError> {
116        if self.logical_key.is_empty() {
117            return Err(CloudHomeError::Configuration(
118                "object slot logical key is empty".to_string(),
119            ));
120        }
121        if matches!(&self.physical, PhysicalObjectLocator::Opaque(value) if value.is_empty()) {
122            return Err(CloudHomeError::Configuration(
123                "object slot provider locator is empty".to_string(),
124            ));
125        }
126        Ok(())
127    }
128
129    pub fn logical_key(&self) -> &str {
130        &self.logical_key
131    }
132
133    pub fn physical(&self) -> &PhysicalObjectLocator {
134        &self.physical
135    }
136}
137
138/// Opaque provider revision for an exact cloud object.
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct CloudObjectVersion(String);
141
142impl CloudObjectVersion {
143    pub fn from_provider(value: String) -> Result<Self, CloudHomeError> {
144        if value.is_empty() {
145            return Err(CloudHomeError::Configuration(
146                "cloud object version token is empty".to_string(),
147            ));
148        }
149        Ok(Self(value))
150    }
151
152    pub fn as_provider(&self) -> &str {
153        &self.0
154    }
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub struct CloudVersionedObject {
159    pub bytes: Vec<u8>,
160    pub version: CloudObjectVersion,
161}
162
163pub type CloudObjectStream =
164    Pin<Box<dyn Stream<Item = Result<Bytes, CloudHomeError>> + Send + 'static>>;
165
166#[derive(Debug, thiserror::Error)]
167pub enum CloudFileReadError {
168    #[error(transparent)]
169    Source(#[from] CloudHomeError),
170    #[error("local destination failed: {0}")]
171    Local(String),
172}
173
174pub async fn write_cloud_object_stream(
175    destination: &Path,
176    stream: CloudObjectStream,
177) -> Result<u64, CloudFileReadError> {
178    crate::local_blob::write_byte_stream_atomic(destination, stream)
179        .await
180        .map_err(|error| match error {
181            crate::local_blob::ByteStreamWriteError::Source(error) => {
182                CloudFileReadError::Source(error)
183            }
184            crate::local_blob::ByteStreamWriteError::Local(error) => {
185                CloudFileReadError::Local(error)
186            }
187        })
188}
189
190impl CloudHomeError {
191    /// Whether the failure is transient — worth retrying the operation unchanged —
192    /// or a fault that will not resolve until the missing object appears or the user
193    /// fixes the configuration. A transport or local-I/O failure is transient
194    /// (`true`); a missing object, a misconfiguration, or absent/invalid credentials
195    /// are not (`false`).
196    pub fn is_retryable(&self) -> bool {
197        match self {
198            CloudHomeError::Transport(_) | CloudHomeError::Io(_) => true,
199            CloudHomeError::CleanupFailed { operation, .. }
200            | CloudHomeError::UnresolvedOutcome { operation, .. } => operation.is_retryable(),
201            CloudHomeError::NotFound(_)
202            | CloudHomeError::AlreadyExists(_)
203            | CloudHomeError::Configuration(_) => false,
204        }
205    }
206
207    pub fn cleanup_causes(&self) -> Option<(&CloudHomeError, &CloudHomeError)> {
208        match self {
209            Self::CleanupFailed { operation, cleanup } => Some((operation, cleanup)),
210            _ => None,
211        }
212    }
213}
214
215/// Information needed to join a cloud home from another device.
216///
217/// The compact tagged shape (short `t` tags) is shared by invite codes and
218/// restore codes — both wrap this same type, so a code adding neither weight
219/// nor a second serde shape to carry around.
220///
221/// `Debug` is hand-written so the S3 `secret_key` prints as `<redacted>` —
222/// `{:?}` in an error path cannot leak the storage credential.
223#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
224#[serde(tag = "t")]
225pub enum CloudHomeJoinInfo {
226    #[serde(rename = "s3")]
227    S3 {
228        bucket: String,
229        region: String,
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        endpoint: Option<String>,
232        access_key: String,
233        secret_key: String,
234        #[serde(default, skip_serializing_if = "Option::is_none")]
235        key_prefix: Option<String>,
236    },
237    #[serde(rename = "gd")]
238    GoogleDrive { folder_id: String },
239    /// `folder_path` matches `CloudHomeConfig.dropbox_folder_path`, whose
240    /// `dropbox_` is the flat-config provider prefix (like `s3_bucket`) — one
241    /// name for this value everywhere it's carried.
242    #[serde(rename = "db")]
243    Dropbox { folder_path: String },
244    #[serde(rename = "od")]
245    OneDrive { drive_id: String, folder_id: String },
246    #[serde(rename = "ck")]
247    CloudKit,
248    #[serde(rename = "cks")]
249    CloudKitShare {
250        share_url: String,
251        owner_name: String,
252        zone_name: String,
253    },
254}
255
256impl std::fmt::Debug for CloudHomeJoinInfo {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        match self {
259            CloudHomeJoinInfo::S3 {
260                bucket,
261                region,
262                endpoint,
263                access_key,
264                secret_key: _,
265                key_prefix,
266            } => f
267                .debug_struct("S3")
268                .field("bucket", bucket)
269                .field("region", region)
270                .field("endpoint", endpoint)
271                .field("access_key", access_key)
272                .field("secret_key", &"<redacted>")
273                .field("key_prefix", key_prefix)
274                .finish(),
275            CloudHomeJoinInfo::GoogleDrive { folder_id } => f
276                .debug_struct("GoogleDrive")
277                .field("folder_id", folder_id)
278                .finish(),
279            CloudHomeJoinInfo::Dropbox { folder_path } => f
280                .debug_struct("Dropbox")
281                .field("folder_path", folder_path)
282                .finish(),
283            CloudHomeJoinInfo::OneDrive {
284                drive_id,
285                folder_id,
286            } => f
287                .debug_struct("OneDrive")
288                .field("drive_id", drive_id)
289                .field("folder_id", folder_id)
290                .finish(),
291            CloudHomeJoinInfo::CloudKit => f.write_str("CloudKit"),
292            CloudHomeJoinInfo::CloudKitShare {
293                share_url,
294                owner_name,
295                zone_name,
296            } => f
297                .debug_struct("CloudKitShare")
298                .field("share_url", share_url)
299                .field("owner_name", owner_name)
300                .field("zone_name", zone_name)
301                .finish(),
302        }
303    }
304}
305
306impl CloudHomeJoinInfo {
307    pub fn cloud_provider(&self) -> crate::config::CloudProvider {
308        use crate::config::CloudProvider;
309        match self {
310            CloudHomeJoinInfo::S3 { .. } => CloudProvider::S3,
311            CloudHomeJoinInfo::GoogleDrive { .. } => CloudProvider::GoogleDrive,
312            CloudHomeJoinInfo::Dropbox { .. } => CloudProvider::Dropbox,
313            CloudHomeJoinInfo::OneDrive { .. } => CloudProvider::OneDrive,
314            CloudHomeJoinInfo::CloudKit | CloudHomeJoinInfo::CloudKitShare { .. } => {
315                CloudProvider::CloudKit
316            }
317        }
318    }
319}
320
321#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
322#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
323pub enum CloudAccessState {
324    Present {
325        member_pubkey: String,
326        provider_account_email: Option<String>,
327    },
328    Absent {
329        member_pubkey: String,
330        provider_account_email: Option<String>,
331    },
332}
333
334#[derive(Clone, Debug, PartialEq, Eq)]
335pub enum CloudAccessOutcome {
336    Present(CloudHomeJoinInfo),
337    Absent(RevokeOutcome),
338}
339
340/// Whether a backend actually withdrew a removed member's storage credential.
341///
342/// Consumer clouds unshare the folder and report [`RevokeOutcome::Revoked`].
343/// Shared-credential backends (S3) hand out one static bucket key that cannot be
344/// withdrawn from a single member and report [`RevokeOutcome::Unsupported`].
345/// Removal proceeds either way: revoking chain membership and rotating the
346/// store key — not withdrawing the credential — is what protects post-removal
347/// content, so `Unsupported` is a truthful outcome, not a failure to paper over.
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum RevokeOutcome {
350    Revoked,
351    Unsupported,
352}
353
354impl CloudAccessState {
355    pub fn member_pubkey(&self) -> &str {
356        match self {
357            Self::Present { member_pubkey, .. } | Self::Absent { member_pubkey, .. } => {
358                member_pubkey
359            }
360        }
361    }
362
363    pub fn provider_account_email(&self) -> Option<&str> {
364        match self {
365            Self::Present {
366                provider_account_email,
367                ..
368            }
369            | Self::Absent {
370                provider_account_email,
371                ..
372            } => provider_account_email.as_deref(),
373        }
374    }
375
376    pub fn require_provider_email(&self, provider: &str) -> Result<&str, CloudHomeError> {
377        require_provider_email(provider, self.provider_account_email())
378    }
379}
380
381fn require_provider_email<'a>(
382    provider: &str,
383    email: Option<&'a str>,
384) -> Result<&'a str, CloudHomeError> {
385    match email {
386        Some(email) if !email.is_empty() => Ok(email),
387        _ => Err(CloudHomeError::Configuration(format!(
388            "{provider} sharing requires the invitee's provider account email"
389        ))),
390    }
391}
392
393/// The HTTP `Range` header value for a ranged GET. `start` is inclusive and
394/// `end` is exclusive (the `CloudHome` contract); the header is inclusive on
395/// both ends, so the upper bound is `end - 1`. The one definition every backend
396/// — both S3 transports and the OAuth REST backends — uses.
397pub fn range_header(start: u64, end: u64) -> String {
398    format!("bytes={start}-{}", end.saturating_sub(1))
399}
400
401/// Reports how many bytes of a `write` have reached the backend so far.
402/// Called with the cumulative byte count as the body uploads; backends that
403/// can't observe sub-call progress call it once at the end with the full size.
404/// The count is of the bytes handed to `write` (the encrypted payload).
405pub type UploadProgress<'a> = dyn Fn(u64) + Send + Sync + 'a;
406
407/// Chunk size the in-memory test backend uses to drive its `UploadProgress`
408/// callback in several ticks. Real providers whose resumable API mandates a
409/// specific alignment (OneDrive 320 KiB multiples, Google Drive 256 KiB
410/// multiples, S3 5 MiB minimum parts) define their own constant.
411#[cfg(any(test, feature = "test-utils"))]
412pub(crate) const PROGRESS_CHUNK_SIZE: usize = 4 * 1024 * 1024;
413
414/// A progress sink that discards its reports. For `write` calls whose payload
415/// is a small control file (head pointers, the snapshot) where no per-file
416/// progress bar is driven — only the blob outbox surfaces progress.
417pub fn no_progress() -> impl Fn(u64) + Send + Sync {
418    |_| {}
419}
420
421/// A blob as a **sized stream of already-final bytes**: sealed chunks for an
422/// encrypted home, plaintext for a browsable one. Encryption-agnostic and
423/// concrete (no `dyn Stream`). [`next_part`](BlobBody::next_part) hands the bytes to a streaming
424/// upload in bounded windows so a large blob is never held whole in memory; the
425/// only [`collect`](BlobBody::collect) is the single-request path for blobs at or
426/// below a provider's multipart threshold.
427///
428/// Built by the cipher layer (`CloudCipher::open_body`), which knows scope→key and
429/// plaintext-vs-encrypted, or by [`from_bytes`](BlobBody::from_bytes) for an
430/// in-memory control object / the test backend.
431pub struct BlobBody {
432    /// Total bytes this body will yield: the encrypted length (see
433    /// [`crate::encryption::chunked_encrypted_len`]) for a sealed body, or the
434    /// plaintext length for a passthrough one.
435    len: u64,
436    source: BlobSource,
437    /// Final bytes produced by the source but not yet handed out by `next_part`.
438    carry: BytesMut,
439}
440
441/// Where a [`BlobBody`]'s final bytes come from.
442enum BlobSource {
443    /// Already-final bytes, handed out once. A control object's sealed bytes, a
444    /// small in-memory write, or the in-memory test backend's payload.
445    Buffered(Bytes),
446    /// Plaintext read incrementally from a local file and, for an encrypted home,
447    /// sealed one 64 KiB chunk at a time into the `[base_nonce][chunk]...` form.
448    File {
449        reader: PlaintextReader,
450        /// Final bytes emitted before the nonce/plaintext stream.
451        prefix: Bytes,
452        /// `Some` seals each chunk under the scope's key; `None` passes the
453        /// plaintext through (a browsable home).
454        sealer: Option<ChunkSealer>,
455        /// The base nonce still owed before the first sealed chunk (encrypted only).
456        nonce_pending: bool,
457        /// Whether any plaintext chunk has been sealed — distinguishes a truly
458        /// empty file (which still seals one tag-only chunk, matching
459        /// `EncryptionService::encrypt`) from one that produced chunks and then
460        /// drained.
461        sealed_any: bool,
462        eof: bool,
463    },
464}
465
466impl BlobSource {
467    /// The next run of final bytes, or `None` once the source is exhausted.
468    async fn next_chunk(&mut self) -> Result<Option<Bytes>, CloudHomeError> {
469        match self {
470            BlobSource::Buffered(b) => {
471                if b.is_empty() {
472                    Ok(None)
473                } else {
474                    Ok(Some(std::mem::take(b)))
475                }
476            }
477            BlobSource::File {
478                reader,
479                prefix,
480                sealer,
481                nonce_pending,
482                sealed_any,
483                eof,
484            } => {
485                if !prefix.is_empty() {
486                    return Ok(Some(std::mem::take(prefix)));
487                }
488                if *nonce_pending {
489                    *nonce_pending = false;
490                    if let Some(s) = sealer {
491                        return Ok(Some(Bytes::copy_from_slice(&s.base_nonce())));
492                    }
493                }
494                if *eof {
495                    return Ok(None);
496                }
497                let chunk = reader
498                    .next_chunk(CHUNK_SIZE)
499                    .await
500                    .map_err(CloudHomeError::Transport)?;
501                if chunk.is_empty() {
502                    *eof = true;
503                    // A sealed empty file still emits one tag-only chunk, matching
504                    // `EncryptionService::encrypt`; a plaintext file (or a sealed
505                    // one that already produced chunks) ends here.
506                    if let Some(s) = sealer {
507                        if !*sealed_any {
508                            *sealed_any = true;
509                            return Ok(Some(Bytes::from(s.seal_chunk(&[]))));
510                        }
511                    }
512                    return Ok(None);
513                }
514                match sealer {
515                    Some(s) => {
516                        *sealed_any = true;
517                        Ok(Some(Bytes::from(s.seal_chunk(&chunk))))
518                    }
519                    None => Ok(Some(Bytes::from(chunk))),
520                }
521            }
522        }
523    }
524}
525
526impl BlobBody {
527    /// A body over already-final in-memory bytes — a sealed control object, or a
528    /// test payload. `len` is the byte count.
529    pub fn from_bytes(data: Vec<u8>) -> Self {
530        BlobBody {
531            len: data.len() as u64,
532            source: BlobSource::Buffered(Bytes::from(data)),
533            carry: BytesMut::new(),
534        }
535    }
536
537    pub async fn from_file(path: &Path) -> Result<Self, String> {
538        let len = crate::local_blob::file_len(path).await?;
539        let reader = crate::local_blob::open_reader(path).await?;
540        Ok(Self::from_file_with_prefix(len, reader, None, Vec::new()))
541    }
542
543    #[cfg(feature = "test-utils")]
544    pub fn from_test_reader(len: u64, reader: PlaintextReader) -> Self {
545        Self::from_file_with_prefix(len, reader, None, Vec::new())
546    }
547
548    pub(crate) fn from_file_with_prefix(
549        len: u64,
550        reader: PlaintextReader,
551        sealer: Option<ChunkSealer>,
552        prefix: Vec<u8>,
553    ) -> Self {
554        let nonce_pending = sealer.is_some();
555        BlobBody {
556            len,
557            source: BlobSource::File {
558                reader,
559                prefix: Bytes::from(prefix),
560                sealer,
561                nonce_pending,
562                sealed_any: false,
563                eof: false,
564            },
565            carry: BytesMut::new(),
566        }
567    }
568
569    /// Total bytes this body yields (encrypted or plaintext length).
570    pub fn len(&self) -> u64 {
571        self.len
572    }
573
574    /// Whether the body yields no bytes.
575    pub fn is_empty(&self) -> bool {
576        self.len == 0
577    }
578
579    /// Pull bytes from the source until `carry` holds at least `min` or the source
580    /// is exhausted.
581    async fn fill(&mut self, min: usize) -> Result<(), CloudHomeError> {
582        while self.carry.len() < min {
583            match self.source.next_chunk().await? {
584                Some(b) => self.carry.extend_from_slice(&b),
585                None => break,
586            }
587        }
588        Ok(())
589    }
590
591    /// Return at least `min` bytes — exactly `min` when more remain, the remainder
592    /// at EOF — or `None` once fully drained. The driver calls this with the
593    /// provider's part size, so every part except the last is exactly that size.
594    pub async fn next_part(&mut self, min: usize) -> Result<Option<Bytes>, CloudHomeError> {
595        let min = min.max(1);
596        self.fill(min).await?;
597        if self.carry.is_empty() {
598            return Ok(None);
599        }
600        let take = self.carry.len().min(min);
601        Ok(Some(self.carry.split_to(take).freeze()))
602    }
603
604    /// Drain the whole body into one `Vec`. Used ONLY by the single-request upload
605    /// path for blobs at or below a provider's multipart threshold (bounded small).
606    pub async fn collect(mut self) -> Result<Vec<u8>, CloudHomeError> {
607        let mut out = Vec::with_capacity(self.len as usize);
608        out.extend_from_slice(&self.carry);
609        self.carry.clear();
610        while let Some(b) = self.source.next_chunk().await? {
611            out.extend_from_slice(&b);
612        }
613        Ok(out)
614    }
615}
616
617/// The one per-provider streaming-upload surface: a session that accepts ordered
618/// parts and commits. The central [`write_blob`] driver opens one of these for a
619/// large blob and pumps [`BlobBody`] parts into it — no backend writes its own
620/// upload loop, collect, or progress call.
621#[async_trait]
622pub trait PartSink: Send {
623    /// Bytes per part. Every part except the last is exactly this; the last is the
624    /// remainder. Encodes each provider's required part size (S3 ≥ 5 MiB, OneDrive
625    /// 320 KiB multiples, Drive 256 KiB multiples, ...).
626    fn part_size(&self) -> usize;
627
628    /// Send one part. `offset` is its byte offset in the blob; `is_last` marks the
629    /// final part (providers that commit on the last call use it).
630    async fn send_part(
631        &mut self,
632        part: Bytes,
633        offset: u64,
634        is_last: bool,
635    ) -> Result<(), CloudHomeError>;
636
637    /// Cancel the open upload and remove its unpublished provider state. The
638    /// upload owner awaits this operation and returns any cleanup failure to its
639    /// caller; `Drop` must never block or terminate the process.
640    async fn abort(&mut self) -> Result<(), CloudHomeError>;
641
642    /// Commit the upload (e.g. S3 `complete_multipart_upload`); a no-op where the
643    /// last `send_part` already committed.
644    async fn finish(self: Box<Self>) -> Result<(), CloudHomeError>;
645}
646
647/// A boxed [`PartSink`] borrowing its home for `'a`.
648pub type BoxPartSink<'a> = Box<dyn PartSink + 'a>;
649
650async fn abort_part_sink(sink: &mut dyn PartSink, operation: CloudHomeError) -> CloudHomeError {
651    if matches!(&operation, CloudHomeError::CleanupFailed { .. }) {
652        return operation;
653    }
654    match sink.abort().await {
655        Ok(()) => operation,
656        Err(cleanup) => CloudHomeError::CleanupFailed {
657            operation: Box::new(operation),
658            cleanup: Box::new(cleanup),
659        },
660    }
661}
662
663/// The central upload driver: pick single-request vs multipart by size and pump
664/// the parts. A blob at or below the home's `multipart_threshold` goes up as one
665/// bounded `put_object`; a larger one opens a multipart/resumable session and
666/// streams [`BlobBody`] parts into it, reporting cumulative progress. The trait's
667/// `write` is this; no backend overrides it.
668async fn write_blob<C: CloudHome + ?Sized>(
669    home: &C,
670    key: &str,
671    mut body: BlobBody,
672    progress: &UploadProgress<'_>,
673) -> Result<(), CloudHomeError> {
674    if body.len() <= home.multipart_threshold() {
675        let data = body.collect().await?;
676        let n = data.len() as u64;
677        home.put_object(key, data).await?;
678        progress(n);
679        return Ok(());
680    }
681    let mut sink = home.open_multipart(key, body.len()).await?;
682    let part_size = sink.part_size();
683    let total = body.len();
684    let mut offset = 0u64;
685    loop {
686        let part = match body.next_part(part_size).await {
687            Ok(Some(part)) => part,
688            Ok(None) if offset == total => break,
689            Ok(None) => {
690                let operation = CloudHomeError::Transport(format!(
691                    "upload body for {key} ended after {offset} of {total} bytes"
692                ));
693                return Err(abort_part_sink(sink.as_mut(), operation).await);
694            }
695            Err(operation) => {
696                return Err(abort_part_sink(sink.as_mut(), operation).await);
697            }
698        };
699        let n = part.len() as u64;
700        let is_last = offset + n >= total;
701        if let Err(operation) = sink.send_part(part, offset, is_last).await {
702            return Err(abort_part_sink(sink.as_mut(), operation).await);
703        }
704        offset += n;
705        progress(offset);
706    }
707    sink.finish().await
708}
709
710/// Low-level cloud storage. Implementations handle a single store.
711///
712/// All methods deal in raw bytes. No encryption or path layout logic.
713///
714#[async_trait]
715pub trait ExactSlotStorage: Send + Sync {
716    async fn provider_binding(
717        &self,
718    ) -> Result<crate::sync::storage::ResolvedProviderBinding, CloudHomeError>;
719
720    async fn cross_principal_evidence(
721        &self,
722    ) -> Result<crate::sync::provider::CrossPrincipalProviderEvidence, CloudHomeError> {
723        use crate::sync::provider::CrossPrincipalProviderEvidence;
724        use crate::sync::storage::{GoogleDriveCorpus, StoreProviderBinding};
725
726        match self.provider_binding().await?.store {
727            StoreProviderBinding::GoogleDrive {
728                corpus: GoogleDriveCorpus::SharedDrive { .. },
729            } => Ok(CrossPrincipalProviderEvidence::GoogleSharedDrive),
730            StoreProviderBinding::Dropbox { .. } => {
731                Ok(CrossPrincipalProviderEvidence::DropboxSharedNamespace)
732            }
733            StoreProviderBinding::OneDrive { .. } => {
734                Ok(CrossPrincipalProviderEvidence::OneDriveSharedFolder)
735            }
736            StoreProviderBinding::CloudKit { .. } => Err(CloudHomeError::Configuration(
737                "CloudKit exact-slot adapter did not supply accepted-share evidence".to_string(),
738            )),
739            StoreProviderBinding::GoogleDrive { .. } => Err(CloudHomeError::Configuration(
740                "Google Drive cross-principal access requires a shared drive".to_string(),
741            )),
742            StoreProviderBinding::S3 { .. } => Err(CloudHomeError::Configuration(
743                "S3 has no cross-principal provider evidence".to_string(),
744            )),
745        }
746    }
747
748    async fn allocate_slot(&self, logical_key: &str) -> Result<ObjectSlot, CloudHomeError>;
749
750    async fn create_at(
751        &self,
752        slot: &ObjectSlot,
753        body: BlobBody,
754        progress: &UploadProgress<'_>,
755    ) -> Result<(), CloudHomeError>;
756
757    async fn read_at(&self, slot: &ObjectSlot) -> Result<Vec<u8>, CloudHomeError>;
758
759    async fn read_range_at(
760        &self,
761        slot: &ObjectSlot,
762        start: u64,
763        end: u64,
764    ) -> Result<Vec<u8>, CloudHomeError>;
765
766    async fn read_at_to_file(
767        &self,
768        slot: &ObjectSlot,
769        destination: &Path,
770    ) -> Result<(), CloudFileReadError>;
771
772    async fn delete_at(&self, slot: &ObjectSlot) -> Result<(), CloudHomeError>;
773}
774
775#[async_trait]
776pub trait CloudHome: Send + Sync {
777    fn exact_slot_storage(self: Arc<Self>) -> Option<Arc<dyn ExactSlotStorage>> {
778        None
779    }
780
781    /// Verify the backend is reachable with the configured credentials.
782    /// Setup flows call this *before* persisting credentials, so a typo or
783    /// missing bucket fails fast at setup time instead of via a delayed
784    /// reconnect banner. Default implementation issues a no-op list against
785    /// a sentinel prefix — backends override with cheaper provider-specific
786    /// auth checks (e.g. S3 HeadBucket) where available.
787    async fn probe(&self) -> Result<(), CloudHomeError> {
788        self.list("__coven_probe__").await.map(drop)
789    }
790
791    /// One bounded single-request upload, creating or overwriting `key`. Used only
792    /// for blobs at or below [`multipart_threshold`](CloudHome::multipart_threshold);
793    /// large blobs stream through [`open_multipart`](CloudHome::open_multipart).
794    async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError>;
795
796    /// Open a streaming multipart/resumable upload for `total_len` bytes, returning
797    /// the [`PartSink`] the driver pumps ordered parts into.
798    async fn open_multipart<'a>(
799        &'a self,
800        key: &str,
801        total_len: u64,
802    ) -> Result<BoxPartSink<'a>, CloudHomeError>;
803
804    /// Blobs at or below this size go via [`put_object`](CloudHome::put_object);
805    /// larger ones stream via [`open_multipart`](CloudHome::open_multipart).
806    fn multipart_threshold(&self) -> u64;
807
808    /// Write a sized [`BlobBody`] to `key`. Not overridden — the central
809    /// [`write_blob`] driver picks single-request vs multipart and pumps the
810    /// parts, reporting cumulative bytes through `progress` for the per-file bar.
811    async fn write(
812        &self,
813        key: &str,
814        body: BlobBody,
815        progress: &UploadProgress<'_>,
816    ) -> Result<(), CloudHomeError> {
817        write_blob(self, key, body, progress).await
818    }
819
820    /// Read the full contents of a key.
821    async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError>;
822
823    /// Read a byte range from a key. `start` is inclusive, `end` is exclusive.
824    async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError>;
825
826    /// List all keys under a prefix.
827    async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError>;
828
829    /// Delete a key. Not an error if the key does not exist.
830    async fn delete(&self, key: &str) -> Result<(), CloudHomeError>;
831
832    /// Check whether a key exists.
833    async fn exists(&self, key: &str) -> Result<bool, CloudHomeError>;
834
835    /// Set the provider's access for one stable member principal to the absolute
836    /// desired state. Implementations read the authoritative permission state,
837    /// create/update/delete as required, then read it back and verify the desired
838    /// state. Repeating a request after an unknown outcome is therefore
839    /// idempotent. `Present` returns connection information; `Absent` returns
840    /// whether this provider supports withdrawing one member's credential.
841    async fn set_access(
842        &self,
843        desired: CloudAccessState,
844    ) -> Result<CloudAccessOutcome, CloudHomeError>;
845}
846
847#[cfg(test)]
848mod object_slot_tests {
849    use super::*;
850
851    #[test]
852    fn deserialization_rejects_empty_slot_components() {
853        assert!(serde_json::from_str::<ObjectSlot>(
854            r#"{"logical_key":"","physical":{"kind":"logical_key"}}"#
855        )
856        .is_err());
857        assert!(serde_json::from_str::<ObjectSlot>(
858            r#"{"logical_key":"object","physical":{"kind":"opaque","value":""}}"#
859        )
860        .is_err());
861    }
862}
863
864#[cfg(test)]
865mod join_info_tests {
866    use super::*;
867
868    /// The wire shape is the compact `{"t": "<short-tag>", ...}` form, not the
869    /// derive default's `{"VariantName": {...}}` — invite and restore codes
870    /// both wrap this type and rely on it staying compact.
871    #[test]
872    fn wire_shape_uses_short_t_tags() {
873        let cases = [
874            (
875                CloudHomeJoinInfo::S3 {
876                    bucket: "b".to_string(),
877                    region: "r".to_string(),
878                    endpoint: None,
879                    access_key: "ak".to_string(),
880                    secret_key: "sk".to_string(),
881                    key_prefix: None,
882                },
883                "s3",
884            ),
885            (
886                CloudHomeJoinInfo::GoogleDrive {
887                    folder_id: "f".to_string(),
888                },
889                "gd",
890            ),
891            (
892                CloudHomeJoinInfo::Dropbox {
893                    folder_path: "/p".to_string(),
894                },
895                "db",
896            ),
897            (
898                CloudHomeJoinInfo::OneDrive {
899                    drive_id: "d".to_string(),
900                    folder_id: "f".to_string(),
901                },
902                "od",
903            ),
904            (CloudHomeJoinInfo::CloudKit, "ck"),
905            (
906                CloudHomeJoinInfo::CloudKitShare {
907                    share_url: "https://share.example".to_string(),
908                    owner_name: "owner".to_string(),
909                    zone_name: "zone".to_string(),
910                },
911                "cks",
912            ),
913        ];
914        for (info, tag) in cases {
915            let json = serde_json::to_value(&info).unwrap();
916            assert_eq!(json["t"], tag, "{info:?} must tag as {tag:?}: {json}");
917        }
918    }
919
920    #[test]
921    fn debug_redacts_s3_secret_key() {
922        let info = CloudHomeJoinInfo::S3 {
923            bucket: "my-bucket".to_string(),
924            region: "us-east-1".to_string(),
925            endpoint: None,
926            access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
927            secret_key: "s3-secret-value-do-not-print".to_string(),
928            key_prefix: None,
929        };
930        let debug = format!("{info:?}");
931
932        assert!(debug.contains("<redacted>"), "{debug}");
933        assert!(debug.contains("my-bucket"), "{debug}");
934        assert!(debug.contains("AKIAIOSFODNN7EXAMPLE"), "{debug}");
935        assert!(
936            !debug.contains("s3-secret-value-do-not-print"),
937            "S3 secret key leaked: {debug}"
938        );
939    }
940}
941
942#[cfg(test)]
943mod retryable_tests {
944    use super::*;
945
946    #[test]
947    fn transport_and_io_are_retryable_config_and_not_found_are_not() {
948        assert!(CloudHomeError::Transport("timeout".to_string()).is_retryable());
949        assert!(CloudHomeError::Io(std::io::Error::other("disk")).is_retryable());
950        assert!(!CloudHomeError::Configuration("bucket not set".to_string()).is_retryable());
951        assert!(!CloudHomeError::NotFound("key".to_string()).is_retryable());
952        assert!(!CloudHomeError::AlreadyExists("key".to_string()).is_retryable());
953    }
954}
955
956#[cfg(test)]
957mod streaming_tests {
958    use super::*;
959    use crate::encryption::{EncryptionService, CHUNK_SIZE};
960    use std::collections::HashMap;
961    use std::sync::atomic::{AtomicUsize, Ordering};
962    use std::sync::Mutex;
963
964    fn service() -> EncryptionService {
965        EncryptionService::from_key([7u8; 32])
966    }
967
968    /// Build a sealed [`BlobBody`] over a temp file holding `plaintext`. The
969    /// returned `TempDir` keeps the file alive for the reader's life.
970    async fn sealed_body(
971        service: &EncryptionService,
972        plaintext: &[u8],
973    ) -> (tempfile::TempDir, BlobBody) {
974        let dir = tempfile::tempdir().unwrap();
975        let path = dir.path().join("blob.bin");
976        std::fs::write(&path, plaintext).unwrap();
977        let reader = crate::local_blob::open_reader(&path).await.unwrap();
978        let body = BlobBody::from_file_with_prefix(
979            crate::encryption::chunked_encrypted_len(plaintext.len() as u64),
980            reader,
981            Some(service.sealer(plaintext.len() as u64, b"storage-cloud-test")),
982            Vec::new(),
983        );
984        (dir, body)
985    }
986
987    /// Drain a body via `next_part(min)`, concatenating every part.
988    async fn drain(mut body: BlobBody, min: usize) -> Vec<u8> {
989        let mut out = Vec::new();
990        while let Some(part) = body.next_part(min).await.unwrap() {
991            // Every part but the last is exactly `min` bytes.
992            out.extend_from_slice(&part);
993        }
994        out
995    }
996
997    /// A sealed body's concatenated `next_part` output decrypts to the original
998    /// plaintext, across the chunk boundaries that matter and several part sizes.
999    #[tokio::test]
1000    async fn sealed_body_streams_then_decrypts() {
1001        let service = service();
1002        for &len in &[
1003            0usize,
1004            1,
1005            CHUNK_SIZE - 1,
1006            CHUNK_SIZE,
1007            CHUNK_SIZE + 1,
1008            200_000,
1009        ] {
1010            let plaintext: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
1011            for &min in &[1usize, 100, CHUNK_SIZE, CHUNK_SIZE + 13, 1 << 20] {
1012                let (_dir, body) = sealed_body(&service, &plaintext).await;
1013                let expected_len = body.len();
1014                let sealed = drain(body, min).await;
1015                assert_eq!(
1016                    sealed.len() as u64,
1017                    expected_len,
1018                    "streamed length wrong for len={len} min={min}"
1019                );
1020                assert_eq!(
1021                    service.decrypt(&sealed, b"storage-cloud-test").unwrap(),
1022                    plaintext,
1023                    "sealed stream failed to round-trip for len={len} min={min}"
1024                );
1025            }
1026        }
1027    }
1028
1029    /// Every non-final part is exactly `part_size`; the last is the remainder.
1030    #[tokio::test]
1031    async fn next_part_returns_exact_part_sizes() {
1032        let service = service();
1033        let plaintext = vec![0u8; CHUNK_SIZE * 3 + 17];
1034        let (_dir, mut body) = sealed_body(&service, &plaintext).await;
1035        let part_size = 1 << 20;
1036        let total = body.len();
1037        let mut offset = 0u64;
1038        while let Some(part) = body.next_part(part_size).await.unwrap() {
1039            offset += part.len() as u64;
1040            if offset < total {
1041                assert_eq!(
1042                    part.len(),
1043                    part_size,
1044                    "a non-final part must be exactly part_size"
1045                );
1046            } else {
1047                assert!(part.len() <= part_size, "the last part is the remainder");
1048            }
1049        }
1050        assert_eq!(offset, total);
1051    }
1052
1053    /// `collect()` yields the same bytes as the concatenated `next_part` output —
1054    /// shown on a deterministic plaintext (passthrough) body so the two bodies
1055    /// produce identical bytes.
1056    #[tokio::test]
1057    async fn collect_equals_next_part_concatenation() {
1058        let dir = tempfile::tempdir().unwrap();
1059        let path = dir.path().join("blob.bin");
1060        let plaintext: Vec<u8> = (0..200_003u32).map(|i| (i % 251) as u8).collect();
1061        std::fs::write(&path, &plaintext).unwrap();
1062
1063        let reader = crate::local_blob::open_reader(&path).await.unwrap();
1064        let streamed = drain(
1065            BlobBody::from_file_with_prefix(plaintext.len() as u64, reader, None, Vec::new()),
1066            4096,
1067        )
1068        .await;
1069        assert_eq!(streamed, plaintext);
1070
1071        let reader = crate::local_blob::open_reader(&path).await.unwrap();
1072        let collected =
1073            BlobBody::from_file_with_prefix(plaintext.len() as u64, reader, None, Vec::new())
1074                .collect()
1075                .await
1076                .unwrap();
1077        assert_eq!(collected, plaintext);
1078        assert_eq!(collected, streamed);
1079    }
1080
1081    /// A test home recording which upload path each write took and assembling the
1082    /// streamed parts so a multipart upload round-trips like a single PUT.
1083    struct RecordingHome {
1084        store: Mutex<HashMap<String, Vec<u8>>>,
1085        put_calls: AtomicUsize,
1086        multipart_calls: AtomicUsize,
1087        abort_calls: AtomicUsize,
1088        threshold: u64,
1089    }
1090
1091    impl RecordingHome {
1092        fn new(threshold: u64) -> Self {
1093            RecordingHome {
1094                store: Mutex::new(HashMap::new()),
1095                put_calls: AtomicUsize::new(0),
1096                multipart_calls: AtomicUsize::new(0),
1097                abort_calls: AtomicUsize::new(0),
1098                threshold,
1099            }
1100        }
1101    }
1102
1103    struct RecordingSink<'a> {
1104        home: &'a RecordingHome,
1105        key: String,
1106        buf: Vec<u8>,
1107    }
1108
1109    #[async_trait]
1110    impl PartSink for RecordingSink<'_> {
1111        fn part_size(&self) -> usize {
1112            4 * 1024 * 1024
1113        }
1114        async fn send_part(
1115            &mut self,
1116            part: Bytes,
1117            offset: u64,
1118            _is_last: bool,
1119        ) -> Result<(), CloudHomeError> {
1120            assert_eq!(
1121                offset,
1122                self.buf.len() as u64,
1123                "parts arrive in order at the running offset"
1124            );
1125            self.buf.extend_from_slice(&part);
1126            Ok(())
1127        }
1128        async fn abort(&mut self) -> Result<(), CloudHomeError> {
1129            self.home.abort_calls.fetch_add(1, Ordering::SeqCst);
1130            Ok(())
1131        }
1132        async fn finish(self: Box<Self>) -> Result<(), CloudHomeError> {
1133            self.home.store.lock().unwrap().insert(self.key, self.buf);
1134            Ok(())
1135        }
1136    }
1137
1138    #[async_trait]
1139    impl CloudHome for RecordingHome {
1140        async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
1141            self.put_calls.fetch_add(1, Ordering::SeqCst);
1142            self.store.lock().unwrap().insert(key.to_string(), data);
1143            Ok(())
1144        }
1145        async fn open_multipart<'a>(
1146            &'a self,
1147            key: &str,
1148            _total_len: u64,
1149        ) -> Result<BoxPartSink<'a>, CloudHomeError> {
1150            self.multipart_calls.fetch_add(1, Ordering::SeqCst);
1151            Ok(Box::new(RecordingSink {
1152                home: self,
1153                key: key.to_string(),
1154                buf: Vec::new(),
1155            }))
1156        }
1157        fn multipart_threshold(&self) -> u64 {
1158            self.threshold
1159        }
1160        async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
1161            self.store
1162                .lock()
1163                .unwrap()
1164                .get(key)
1165                .cloned()
1166                .ok_or_else(|| CloudHomeError::NotFound(key.to_string()))
1167        }
1168        async fn read_range(&self, _k: &str, _s: u64, _e: u64) -> Result<Vec<u8>, CloudHomeError> {
1169            unimplemented!()
1170        }
1171        async fn list(&self, _prefix: &str) -> Result<Vec<String>, CloudHomeError> {
1172            unimplemented!()
1173        }
1174        async fn delete(&self, _key: &str) -> Result<(), CloudHomeError> {
1175            unimplemented!()
1176        }
1177        async fn exists(&self, _key: &str) -> Result<bool, CloudHomeError> {
1178            unimplemented!()
1179        }
1180        async fn set_access(
1181            &self,
1182            _desired: CloudAccessState,
1183        ) -> Result<CloudAccessOutcome, CloudHomeError> {
1184            unimplemented!()
1185        }
1186    }
1187
1188    /// A blob above the threshold streams through multipart, round-trips exactly,
1189    /// and reports monotonic progress reaching the full length.
1190    #[tokio::test]
1191    async fn write_blob_streams_large_blob_with_monotonic_progress() {
1192        let home = RecordingHome::new(8 * 1024 * 1024);
1193        let data: Vec<u8> = (0..20_000_003u32).map(|i| (i % 251) as u8).collect();
1194        let ticks = Mutex::new(Vec::<u64>::new());
1195        let progress = |n: u64| ticks.lock().unwrap().push(n);
1196
1197        home.write("k", BlobBody::from_bytes(data.clone()), &progress)
1198            .await
1199            .unwrap();
1200
1201        assert_eq!(home.multipart_calls.load(Ordering::SeqCst), 1);
1202        assert_eq!(home.put_calls.load(Ordering::SeqCst), 0);
1203        assert_eq!(
1204            home.read("k").await.unwrap(),
1205            data,
1206            "multipart upload round-trips"
1207        );
1208
1209        let ticks = ticks.lock().unwrap();
1210        assert!(ticks.len() >= 2, "several progress ticks: {ticks:?}");
1211        for w in ticks.windows(2) {
1212            assert!(w[1] >= w[0], "progress went backwards: {ticks:?}");
1213        }
1214        assert_eq!(
1215            *ticks.last().unwrap(),
1216            data.len() as u64,
1217            "progress reaches the full length"
1218        );
1219    }
1220
1221    #[tokio::test]
1222    async fn write_blob_aborts_when_the_body_ends_before_its_declared_length() {
1223        let home = RecordingHome::new(1);
1224        let dir = tempfile::tempdir().unwrap();
1225        let path = dir.path().join("short.bin");
1226        std::fs::write(&path, [7; 4]).unwrap();
1227        let reader = crate::local_blob::open_reader(&path).await.unwrap();
1228        let body = BlobBody::from_file_with_prefix(5, reader, None, Vec::new());
1229
1230        let error = home
1231            .write("short", body, &no_progress())
1232            .await
1233            .expect_err("an incomplete body must not commit");
1234
1235        assert!(
1236            error.to_string().contains("ended after 4 of 5 bytes"),
1237            "{error}"
1238        );
1239        assert_eq!(home.abort_calls.load(Ordering::SeqCst), 1);
1240        assert!(!home.store.lock().unwrap().contains_key("short"));
1241    }
1242
1243    struct FailingPartHome {
1244        abort_calls: AtomicUsize,
1245    }
1246
1247    struct FailingPartSink<'a> {
1248        home: &'a FailingPartHome,
1249    }
1250
1251    #[async_trait]
1252    impl PartSink for FailingPartSink<'_> {
1253        fn part_size(&self) -> usize {
1254            2
1255        }
1256
1257        async fn send_part(
1258            &mut self,
1259            _part: Bytes,
1260            _offset: u64,
1261            _is_last: bool,
1262        ) -> Result<(), CloudHomeError> {
1263            Err(CloudHomeError::Transport(
1264                "injected part failure".to_string(),
1265            ))
1266        }
1267
1268        async fn abort(&mut self) -> Result<(), CloudHomeError> {
1269            self.home.abort_calls.fetch_add(1, Ordering::SeqCst);
1270            Err(CloudHomeError::Transport(
1271                "injected abort failure".to_string(),
1272            ))
1273        }
1274
1275        async fn finish(self: Box<Self>) -> Result<(), CloudHomeError> {
1276            panic!("a failed part must not finish")
1277        }
1278    }
1279
1280    #[async_trait]
1281    impl CloudHome for FailingPartHome {
1282        async fn put_object(&self, _key: &str, _data: Vec<u8>) -> Result<(), CloudHomeError> {
1283            panic!("multipart test must not use put_object")
1284        }
1285
1286        async fn open_multipart<'a>(
1287            &'a self,
1288            _key: &str,
1289            _total_len: u64,
1290        ) -> Result<BoxPartSink<'a>, CloudHomeError> {
1291            Ok(Box::new(FailingPartSink { home: self }))
1292        }
1293
1294        fn multipart_threshold(&self) -> u64 {
1295            1
1296        }
1297
1298        async fn read(&self, _key: &str) -> Result<Vec<u8>, CloudHomeError> {
1299            unimplemented!()
1300        }
1301
1302        async fn read_range(
1303            &self,
1304            _key: &str,
1305            _start: u64,
1306            _end: u64,
1307        ) -> Result<Vec<u8>, CloudHomeError> {
1308            unimplemented!()
1309        }
1310
1311        async fn list(&self, _prefix: &str) -> Result<Vec<String>, CloudHomeError> {
1312            unimplemented!()
1313        }
1314
1315        async fn delete(&self, _key: &str) -> Result<(), CloudHomeError> {
1316            unimplemented!()
1317        }
1318
1319        async fn exists(&self, _key: &str) -> Result<bool, CloudHomeError> {
1320            unimplemented!()
1321        }
1322
1323        async fn set_access(
1324            &self,
1325            _desired: CloudAccessState,
1326        ) -> Result<CloudAccessOutcome, CloudHomeError> {
1327            unimplemented!()
1328        }
1329    }
1330
1331    #[tokio::test]
1332    async fn write_blob_aborts_and_preserves_cleanup_failure_when_a_part_fails() {
1333        let home = FailingPartHome {
1334            abort_calls: AtomicUsize::new(0),
1335        };
1336
1337        let error = home
1338            .write(
1339                "part-failure",
1340                BlobBody::from_bytes(vec![1, 2, 3]),
1341                &no_progress(),
1342            )
1343            .await
1344            .expect_err("a failed multipart part must abort its session");
1345
1346        assert_eq!(home.abort_calls.load(Ordering::SeqCst), 1);
1347        assert!(matches!(error, CloudHomeError::CleanupFailed { .. }));
1348        assert!(
1349            error.to_string().contains("injected part failure"),
1350            "{error}"
1351        );
1352        assert!(
1353            error.to_string().contains("injected abort failure"),
1354            "{error}"
1355        );
1356    }
1357
1358    /// A blob at or below the threshold goes through `put_object` as one request.
1359    #[tokio::test]
1360    async fn write_blob_uses_put_object_below_threshold() {
1361        let home = RecordingHome::new(8 * 1024 * 1024);
1362        let data = vec![3u8; 1024];
1363        let total = Mutex::new(0u64);
1364        let progress = |n: u64| *total.lock().unwrap() = n;
1365
1366        home.write("small", BlobBody::from_bytes(data.clone()), &progress)
1367            .await
1368            .unwrap();
1369
1370        assert_eq!(home.put_calls.load(Ordering::SeqCst), 1);
1371        assert_eq!(home.multipart_calls.load(Ordering::SeqCst), 0);
1372        assert_eq!(home.read("small").await.unwrap(), data);
1373        assert_eq!(*total.lock().unwrap(), data.len() as u64);
1374    }
1375}