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 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#[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 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#[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 #[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#[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
431pub fn range_header(start: u64, end: u64) -> String {
436 format!("bytes={start}-{}", end.saturating_sub(1))
437}
438
439pub type UploadProgress<'a> = dyn Fn(u64) + Send + Sync + 'a;
444
445#[cfg(any(test, feature = "test-utils"))]
450pub(crate) const PROGRESS_CHUNK_SIZE: usize = 4 * 1024 * 1024;
451
452pub fn no_progress() -> impl Fn(u64) + Send + Sync {
456 |_| {}
457}
458
459pub struct BlobBody {
470 len: u64,
474 source: BlobSource,
475 carry: BytesMut,
477}
478
479enum BlobSource {
481 Buffered(Bytes),
484 File {
487 reader: PlaintextReader,
488 prefix: Bytes,
490 sealer: Option<ChunkSealer>,
493 nonce_pending: bool,
495 sealed_any: bool,
500 eof: bool,
501 },
502}
503
504impl BlobSource {
505 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 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 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 pub fn len(&self) -> u64 {
609 self.len
610 }
611
612 pub fn is_empty(&self) -> bool {
614 self.len == 0
615 }
616
617 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 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 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#[async_trait]
660pub trait PartSink: Send {
661 fn part_size(&self) -> usize;
665
666 async fn send_part(
669 &mut self,
670 part: Bytes,
671 offset: u64,
672 is_last: bool,
673 ) -> Result<(), CloudHomeError>;
674
675 async fn abort(&mut self) -> Result<(), CloudHomeError>;
679
680 async fn finish(self: Box<Self>) -> Result<(), CloudHomeError>;
683}
684
685pub 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
701async 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#[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 async fn probe(&self) -> Result<(), CloudHomeError> {
826 self.list("__coven_probe__").await.map(drop)
827 }
828
829 async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError>;
833
834 async fn open_multipart<'a>(
837 &'a self,
838 key: &str,
839 total_len: u64,
840 ) -> Result<BoxPartSink<'a>, CloudHomeError>;
841
842 fn multipart_threshold(&self) -> u64;
845
846 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 async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError>;
860
861 async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError>;
863
864 async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError>;
866
867 async fn delete(&self, key: &str) -> Result<(), CloudHomeError>;
869
870 async fn exists(&self, key: &str) -> Result<bool, CloudHomeError>;
872
873 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 #[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 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 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 out.extend_from_slice(&part);
1031 }
1032 out
1033 }
1034
1035 #[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 #[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 #[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 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 #[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 #[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}