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}