Skip to main content

coven_storage/cloud/
blob_body.rs

1use super::*;
2
3/// Reports how many bytes of a `write` have reached the backend so far.
4/// Called with the cumulative byte count as the body uploads; backends that
5/// can't observe sub-call progress call it once at the end with the full size.
6/// The count is of the bytes handed to `write` (the encrypted payload).
7///
8/// Owned and shareable, like [`DownloadProgress`] and [`PreparationProgress`].
9/// A provider that runs its request on Coven's cloud runtime hands the request
10/// body a clone of this handle so the body can report as it streams; a borrowed
11/// callback could not outlive the call that started the request.
12pub type UploadProgress = std::sync::Arc<dyn Fn(u64) + Send + Sync>;
13
14/// One exact upload's progress reporting and absolute pause state. Provider
15/// request bodies consult this before yielding each network chunk, so pausing
16/// stops the active request without closing its upload session; resuming lets
17/// that same request continue from the next byte.
18#[derive(Clone)]
19pub struct UploadControl {
20    progress: UploadProgress,
21    paused: tokio::sync::watch::Receiver<bool>,
22    reported: std::sync::Arc<std::sync::atomic::AtomicU64>,
23}
24
25impl UploadControl {
26    /// A transfer that remains running for its lifetime.
27    pub fn running(progress: UploadProgress) -> Self {
28        let (_sender, paused) = tokio::sync::watch::channel(false);
29        Self::pausable(progress, paused)
30    }
31
32    /// A transfer controlled by the supplied absolute pause state.
33    pub fn pausable(progress: UploadProgress, paused: tokio::sync::watch::Receiver<bool>) -> Self {
34        Self {
35            progress,
36            paused,
37            reported: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
38        }
39    }
40
41    pub async fn wait_until_resumed(&self) {
42        let mut paused = self.paused.clone();
43        while *paused.borrow_and_update() {
44            paused
45                .changed()
46                .await
47                .expect("exact upload owns its pause sender until the provider request finishes");
48        }
49    }
50
51    pub fn report(&self, bytes_done: u64) {
52        use std::sync::atomic::Ordering;
53
54        let previous = self.reported.fetch_max(bytes_done, Ordering::SeqCst);
55        if bytes_done > previous {
56            (self.progress)(bytes_done);
57        }
58    }
59
60    /// Turn one provider part into bounded request-body chunks. The stream owns
61    /// its control handle, so it remains pause-aware after a provider moves the
62    /// request onto its retained network runtime.
63    pub(crate) fn stream_part(
64        &self,
65        part: Bytes,
66        offset: u64,
67    ) -> impl futures_util::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
68        const REQUEST_CHUNK_SIZE: usize = 64 * 1024;
69
70        futures_util::stream::unfold(
71            (part, offset, self.clone()),
72            |(mut remaining, sent, control)| async move {
73                if remaining.is_empty() {
74                    return None;
75                }
76                control.wait_until_resumed().await;
77                let take = remaining.len().min(REQUEST_CHUNK_SIZE);
78                let chunk = remaining.split_to(take);
79                let sent = sent + take as u64;
80                control.report(sent);
81                Some((Ok(chunk), (remaining, sent, control)))
82            },
83        )
84    }
85}
86
87/// Reports how many bytes of a cloud object have arrived from the provider.
88/// The count is cumulative and advances once per received stream buffer.
89pub type DownloadProgress = std::sync::Arc<dyn Fn(u64) + Send + Sync>;
90
91/// Reports how many plaintext source bytes have been consumed while a durable
92/// upload spool is being prepared. Owned because the reader that produces the
93/// sealed body retains it for the lifetime of that stream.
94pub type PreparationProgress = std::sync::Arc<dyn Fn(u64) + Send + Sync>;
95
96/// Chunk size the in-memory test backend uses to drive its `UploadProgress`
97/// callback in several ticks. Real providers whose resumable API mandates a
98/// specific alignment (OneDrive 320 KiB multiples, Google Drive 256 KiB
99/// multiples, S3 5 MiB minimum parts) define their own constant.
100#[cfg(any(test, feature = "test-utils"))]
101pub(crate) const PROGRESS_CHUNK_SIZE: usize = 4 * 1024 * 1024;
102
103/// A progress sink that discards its reports. For `write` calls whose payload
104/// is a small control file (head pointers, the snapshot) where no per-file
105/// progress bar is driven — only the blob outbox surfaces progress.
106pub fn no_progress() -> UploadProgress {
107    std::sync::Arc::new(|_| {})
108}
109
110pub fn no_preparation_progress() -> PreparationProgress {
111    std::sync::Arc::new(|_| {})
112}
113
114pub fn no_download_progress() -> DownloadProgress {
115    std::sync::Arc::new(|_| {})
116}
117
118/// A blob as a **sized stream of already-final bytes**: sealed chunks for an
119/// encrypted home, plaintext for a browsable one. Encryption-agnostic and
120/// concrete (no `dyn Stream`). [`next_part`](BlobBody::next_part) hands the bytes to a streaming
121/// upload in bounded windows so a large blob is never held whole in memory; the
122/// only [`collect`](BlobBody::collect) is the single-request path for blobs at or
123/// below a provider's multipart threshold.
124///
125/// Built by the cipher layer (`CloudCipher::open_body`), which knows scope→key and
126/// plaintext-vs-encrypted, or by [`from_bytes`](BlobBody::from_bytes) for an
127/// in-memory control object / the test backend.
128pub struct BlobBody {
129    /// Total bytes this body will yield: the encrypted length (see
130    /// [`coven_keys::encryption::chunked_encrypted_len`]) for a sealed body, or the
131    /// plaintext length for a passthrough one.
132    len: u64,
133    source: BlobSource,
134    /// Final bytes produced by the source but not yet handed out by `next_part`.
135    carry: BytesMut,
136}
137
138/// Where a [`BlobBody`]'s final bytes come from.
139enum BlobSource {
140    /// Already-final bytes, handed out once. A control object's sealed bytes, a
141    /// small in-memory write, or the in-memory test backend's payload.
142    Buffered(Bytes),
143    /// Plaintext read incrementally from a local file and, for an encrypted home,
144    /// sealed one header-sized chunk at a time.
145    File {
146        reader: PlaintextReader,
147        /// Final bytes emitted before the plaintext stream — the key tag and,
148        /// for an encrypted home, the sealed-blob header.
149        prefix: Bytes,
150        /// `Some` seals each chunk under the scope's key; `None` passes the
151        /// plaintext through (a browsable home).
152        sealer: Option<SealedBlobSealer>,
153        /// Whether any plaintext chunk has been sealed — distinguishes a truly
154        /// empty file (which still seals one tag-only chunk, so opening it
155        /// authenticates its emptiness) from one that produced chunks and then
156        /// drained.
157        sealed_any: bool,
158        eof: bool,
159    },
160}
161
162impl BlobSource {
163    /// The next run of final bytes, or `None` once the source is exhausted.
164    async fn next_chunk(&mut self) -> Result<Option<Bytes>, CloudHomeError> {
165        match self {
166            BlobSource::Buffered(b) => {
167                if b.is_empty() {
168                    Ok(None)
169                } else {
170                    Ok(Some(std::mem::take(b)))
171                }
172            }
173            BlobSource::File {
174                reader,
175                prefix,
176                sealer,
177                sealed_any,
178                eof,
179            } => {
180                if !prefix.is_empty() {
181                    return Ok(Some(std::mem::take(prefix)));
182                }
183                if *eof {
184                    return Ok(None);
185                }
186                // Each read is exactly one chunk of the size this blob's header
187                // declares, so the sealer's framing and the reader's stride are
188                // the same number by construction.
189                let stride = match sealer {
190                    Some(s) => s.header().chunk_size().get() as usize,
191                    None => DEFAULT_BLOB_CHUNK_SIZE.get() as usize,
192                };
193                let chunk = reader
194                    .next_chunk(stride)
195                    .await
196                    .map_err(|error| match error {
197                        crate::local_file::PlaintextChunkError::Remote(error) => {
198                            CloudHomeError::BlobSource(error)
199                        }
200                        crate::local_file::PlaintextChunkError::InvalidContent(message) => {
201                            CloudHomeError::InvalidBlobSource(message)
202                        }
203                        crate::local_file::PlaintextChunkError::Local(error) => {
204                            CloudHomeError::Local(error)
205                        }
206                        crate::local_file::PlaintextChunkError::Decryption { context, source } => {
207                            CloudHomeError::BlobSource(
208                                coven_protocol::objects::StorageError::Decryption {
209                                    context,
210                                    source,
211                                },
212                            )
213                        }
214                    })?;
215                if chunk.is_empty() {
216                    *eof = true;
217                    // A sealed empty file still emits one tag-only chunk, so a
218                    // reader authenticates its emptiness; a plaintext file (or a
219                    // sealed one that already produced chunks) ends here.
220                    if let Some(s) = sealer {
221                        if !*sealed_any {
222                            *sealed_any = true;
223                            return Ok(Some(Bytes::from(s.seal_chunk(&[]))));
224                        }
225                    }
226                    return Ok(None);
227                }
228                match sealer {
229                    Some(s) => {
230                        *sealed_any = true;
231                        Ok(Some(Bytes::from(s.seal_chunk(&chunk))))
232                    }
233                    None => Ok(Some(Bytes::from(chunk))),
234                }
235            }
236        }
237    }
238}
239
240impl BlobBody {
241    /// A body over already-final in-memory bytes — a sealed control object, or a
242    /// test payload. `len` is the byte count.
243    pub fn from_bytes(data: Vec<u8>) -> Self {
244        BlobBody {
245            len: data.len() as u64,
246            source: BlobSource::Buffered(Bytes::from(data)),
247            carry: BytesMut::new(),
248        }
249    }
250
251    pub async fn from_file(path: &Path) -> Result<Self, coven_foundation::atomic_file::FileError> {
252        let len = coven_foundation::local_file::file_len(path).await?;
253        let reader = crate::local_file::open_reader(path).await?;
254        Ok(Self::from_file_with_prefix(len, reader, None, Vec::new()))
255    }
256
257    pub fn from_file_with_prefix(
258        len: u64,
259        reader: PlaintextReader,
260        sealer: Option<SealedBlobSealer>,
261        prefix: Vec<u8>,
262    ) -> Self {
263        BlobBody {
264            len,
265            source: BlobSource::File {
266                reader,
267                prefix: Bytes::from(prefix),
268                sealer,
269                sealed_any: false,
270                eof: false,
271            },
272            carry: BytesMut::new(),
273        }
274    }
275
276    /// Total bytes this body yields (encrypted or plaintext length).
277    pub fn len(&self) -> u64 {
278        self.len
279    }
280
281    /// Whether the body yields no bytes.
282    pub fn is_empty(&self) -> bool {
283        self.len == 0
284    }
285
286    /// Pull bytes from the source until `carry` holds at least `min` or the source
287    /// is exhausted.
288    async fn fill(&mut self, min: usize) -> Result<(), CloudHomeError> {
289        while self.carry.len() < min {
290            match self.source.next_chunk().await? {
291                Some(b) => self.carry.extend_from_slice(&b),
292                None => break,
293            }
294        }
295        Ok(())
296    }
297
298    /// Return at least `min` bytes — exactly `min` when more remain, the remainder
299    /// at EOF — or `None` once fully drained. The driver calls this with the
300    /// provider's part size, so every part except the last is exactly that size.
301    pub async fn next_part(&mut self, min: usize) -> Result<Option<Bytes>, CloudHomeError> {
302        let min = min.max(1);
303        self.fill(min).await?;
304        if self.carry.is_empty() {
305            return Ok(None);
306        }
307        let take = self.carry.len().min(min);
308        Ok(Some(self.carry.split_to(take).freeze()))
309    }
310
311    /// Drain the whole body into one `Vec`. Used ONLY by the single-request upload
312    /// path for blobs at or below a provider's multipart threshold (bounded small).
313    pub async fn collect(mut self) -> Result<Vec<u8>, CloudHomeError> {
314        let mut out = Vec::with_capacity(self.len as usize);
315        out.extend_from_slice(&self.carry);
316        self.carry.clear();
317        while let Some(b) = self.source.next_chunk().await? {
318            out.extend_from_slice(&b);
319        }
320        Ok(out)
321    }
322
323    #[cfg(test)]
324    pub fn from_test_reader(len: u64, reader: PlaintextReader) -> Self {
325        Self::from_file_with_prefix(len, reader, None, Vec::new())
326    }
327}
328
329/// The one per-provider streaming-upload surface: a session that accepts ordered
330/// parts and commits. The central `write_blob` driver opens one of these for a
331/// large blob and pumps [`BlobBody`] parts into it — no backend writes its own
332/// upload loop, collect, or progress call.
333#[async_trait]
334pub trait PartSink: Send {
335    /// Bytes per part. Every part except the last is exactly this; the last is the
336    /// remainder. Encodes each provider's required part size (S3 ≥ 5 MiB, OneDrive
337    /// 320 KiB multiples, Drive 256 KiB multiples, ...).
338    fn part_size(&self) -> usize;
339
340    /// Send one part. `offset` is its byte offset in the blob; `is_last` marks the
341    /// final part (providers that commit on the last call use it).
342    async fn send_part(
343        &mut self,
344        part: Bytes,
345        offset: u64,
346        is_last: bool,
347        control: &UploadControl,
348    ) -> Result<(), CloudHomeError>;
349
350    /// Cancel the open upload and remove its unpublished provider state. The
351    /// upload owner awaits this operation and returns any cleanup failure to its
352    /// caller; `Drop` must never block or terminate the process.
353    async fn abort(&mut self) -> Result<(), CloudHomeError>;
354
355    /// Commit the upload (e.g. S3 `complete_multipart_upload`); a no-op where the
356    /// last `send_part` already committed.
357    async fn finish(self: Box<Self>) -> Result<(), CloudHomeError>;
358}
359
360/// A boxed [`PartSink`] borrowing its home for `'a`.
361pub type BoxPartSink<'a> = Box<dyn PartSink + 'a>;
362
363/// Report an operation's failure, folding in a second failure that happened
364/// while cleaning up after it. Both are kept: the cleanup failure is what left
365/// remote state behind, the operation failure is why cleanup ran at all.
366pub(crate) fn combine_cleanup_failure(
367    operation: CloudHomeError,
368    cleanup: Result<(), CloudHomeError>,
369) -> CloudHomeError {
370    match cleanup {
371        Ok(()) => operation,
372        Err(cleanup) => CloudHomeError::CleanupFailed {
373            operation: Box::new(operation),
374            cleanup: Box::new(cleanup),
375        },
376    }
377}
378
379/// One open multipart upload, including its source body, provider session, and
380/// progress reporting. The operation either finishes the provider session or
381/// awaits its abort and preserves both the operation and cleanup failures.
382pub(crate) struct MultipartUpload<'sink, 'control> {
383    key: String,
384    body: BlobBody,
385    sink: BoxPartSink<'sink>,
386    control: &'control UploadControl,
387}
388
389impl<'sink, 'control> MultipartUpload<'sink, 'control> {
390    pub(crate) fn new(
391        key: &str,
392        body: BlobBody,
393        sink: BoxPartSink<'sink>,
394        control: &'control UploadControl,
395    ) -> Self {
396        Self {
397            key: key.to_string(),
398            body,
399            sink,
400            control,
401        }
402    }
403
404    pub(crate) async fn run(mut self) -> Result<(), CloudHomeError> {
405        let part_size = self.sink.part_size();
406        let total = self.body.len();
407        let mut offset = 0u64;
408        loop {
409            let part = match self.body.next_part(part_size).await {
410                Ok(Some(part)) => part,
411                Ok(None) if offset == total => break,
412                Ok(None) => {
413                    let operation = CloudHomeError::Transport(format!(
414                        "upload body for {} ended after {offset} of {total} bytes",
415                        self.key
416                    ));
417                    return Err(self.abort(operation).await);
418                }
419                Err(operation) => return Err(self.abort(operation).await),
420            };
421            let n = part.len() as u64;
422            let is_last = offset + n >= total;
423            if let Err(operation) = self
424                .sink
425                .send_part(part, offset, is_last, self.control)
426                .await
427            {
428                return Err(self.abort(operation).await);
429            }
430            offset += n;
431            self.control.report(offset);
432        }
433        self.sink.finish().await
434    }
435
436    pub(crate) async fn abort(&mut self, operation: CloudHomeError) -> CloudHomeError {
437        if matches!(&operation, CloudHomeError::CleanupFailed { .. }) {
438            return operation;
439        }
440        combine_cleanup_failure(operation, self.sink.abort().await)
441    }
442}