1pub 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#[derive(Debug, thiserror::Error)]
28pub enum CloudHomeError {
29 #[error("not found: {0}")]
30 NotFound(String),
31 #[error("already exists: {0}")]
32 AlreadyExists(String),
33 #[error("configuration error: {0}")]
38 Configuration(String),
39 #[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#[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#[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#[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 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#[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 #[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#[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
393pub fn range_header(start: u64, end: u64) -> String {
398 format!("bytes={start}-{}", end.saturating_sub(1))
399}
400
401pub type UploadProgress<'a> = dyn Fn(u64) + Send + Sync + 'a;
406
407#[cfg(any(test, feature = "test-utils"))]
412pub(crate) const PROGRESS_CHUNK_SIZE: usize = 4 * 1024 * 1024;
413
414pub fn no_progress() -> impl Fn(u64) + Send + Sync {
418 |_| {}
419}
420
421pub struct BlobBody {
432 len: u64,
436 source: BlobSource,
437 carry: BytesMut,
439}
440
441enum BlobSource {
443 Buffered(Bytes),
446 File {
449 reader: PlaintextReader,
450 prefix: Bytes,
452 sealer: Option<ChunkSealer>,
455 nonce_pending: bool,
457 sealed_any: bool,
462 eof: bool,
463 },
464}
465
466impl BlobSource {
467 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 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 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 pub fn len(&self) -> u64 {
571 self.len
572 }
573
574 pub fn is_empty(&self) -> bool {
576 self.len == 0
577 }
578
579 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 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 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#[async_trait]
622pub trait PartSink: Send {
623 fn part_size(&self) -> usize;
627
628 async fn send_part(
631 &mut self,
632 part: Bytes,
633 offset: u64,
634 is_last: bool,
635 ) -> Result<(), CloudHomeError>;
636
637 async fn abort(&mut self) -> Result<(), CloudHomeError>;
641
642 async fn finish(self: Box<Self>) -> Result<(), CloudHomeError>;
645}
646
647pub 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
663async 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#[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 async fn probe(&self) -> Result<(), CloudHomeError> {
788 self.list("__coven_probe__").await.map(drop)
789 }
790
791 async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError>;
795
796 async fn open_multipart<'a>(
799 &'a self,
800 key: &str,
801 total_len: u64,
802 ) -> Result<BoxPartSink<'a>, CloudHomeError>;
803
804 fn multipart_threshold(&self) -> u64;
807
808 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 async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError>;
822
823 async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError>;
825
826 async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError>;
828
829 async fn delete(&self, key: &str) -> Result<(), CloudHomeError>;
831
832 async fn exists(&self, key: &str) -> Result<bool, CloudHomeError>;
834
835 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 #[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 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 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 out.extend_from_slice(&part);
993 }
994 out
995 }
996
997 #[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 #[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 #[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 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 #[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 #[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}