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