1use std::collections::HashSet;
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use bytes::Bytes;
15
16use coven_foundation::id_provider::{IdRef, UuidProvider};
17
18use super::{
19 combine_cleanup_failure, CloudAccessOutcome, CloudAccessState, CloudHome, CloudHomeError,
20 CloudHomeJoinInfo, CloudObjectVersion, CloudVersionedObject, ConditionalWriteOutcome,
21 ExactSlotStorage, RevokeOutcome,
22};
23use coven_protocol::objects::ObjectSlot;
24
25const CHUNK_SIZE: usize = 10 * 1024 * 1024; const CHUNK_MANIFEST_MAGIC: &[u8] = b"coven-cloudkit-chunk-manifest-v1\0";
27const CHUNK_MANIFEST_SUFFIX: &str = ".manifest";
28
29mod chunking;
30use chunking::*;
31use part_sink::CloudKitPartSink;
32mod exact;
33mod part_sink;
34
35pub trait CloudKitOps: Send + Sync {
39 fn provider_identity(
41 &self,
42 scope: &CloudKitScope,
43 ) -> Result<CloudKitProviderIdentity, CloudHomeError>;
44
45 fn accepted_read_write_share(
48 &self,
49 scope: &CloudKitScope,
50 ) -> Result<CloudKitAcceptedShareRecord, CloudHomeError>;
51
52 fn write_record(
53 &self,
54 scope: &CloudKitScope,
55 key: &str,
56 data: Vec<u8>,
57 ) -> Result<(), CloudHomeError>;
58 fn read_record(&self, scope: &CloudKitScope, key: &str) -> Result<Vec<u8>, CloudHomeError>;
59 fn list_records(
60 &self,
61 scope: &CloudKitScope,
62 prefix: &str,
63 ) -> Result<Vec<String>, CloudHomeError>;
64 fn delete_record(&self, scope: &CloudKitScope, key: &str) -> Result<(), CloudHomeError>;
65 fn record_exists(&self, scope: &CloudKitScope, key: &str) -> Result<bool, CloudHomeError>;
66 fn read_versioned_record(
68 &self,
69 scope: &CloudKitScope,
70 key: &str,
71 ) -> Result<CloudVersionedObject, CloudHomeError>;
72 fn replace_record_if_version(
76 &self,
77 scope: &CloudKitScope,
78 key: &str,
79 expected: &CloudObjectVersion,
80 data: Vec<u8>,
81 ) -> Result<ConditionalWriteOutcome, CloudHomeError>;
82 fn begin_atomic_create(
85 &self,
86 scope: &CloudKitScope,
87 ) -> Result<CloudKitAtomicCreateBatch, CloudHomeError>;
88 fn stage_atomic_create_record(
90 &self,
91 scope: &CloudKitScope,
92 batch: &CloudKitAtomicCreateBatch,
93 record: CloudKitRecordCreate,
94 ) -> Result<(), CloudHomeError>;
95 fn commit_atomic_create(
102 &self,
103 scope: &CloudKitScope,
104 batch: &CloudKitAtomicCreateBatch,
105 ) -> Result<Vec<CloudKitRecordVersion>, CloudHomeError>;
106 fn discard_atomic_create(
110 &self,
111 scope: &CloudKitScope,
112 batch: &CloudKitAtomicCreateBatch,
113 ) -> Result<(), CloudHomeError>;
114 fn delete_record_versions(
117 &self,
118 scope: &CloudKitScope,
119 records: &[CloudKitRecordVersion],
120 ) -> Result<(), CloudHomeError>;
121 fn share_for_member(
122 &self,
123 member_pubkey: &str,
124 ) -> Result<Option<CloudKitShare>, CloudHomeError>;
125 fn grant_share(&self, member_pubkey: &str) -> Result<CloudKitShare, CloudHomeError>;
126 fn revoke_share(&self, member_pubkey: &str) -> Result<(), CloudHomeError>;
127 fn accept_share(&self, share_url: &str) -> Result<CloudKitShare, CloudHomeError>;
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct CloudKitRecordVersion {
132 pub key: String,
133 pub version: CloudObjectVersion,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct CloudKitRecordCreate {
138 pub key: String,
139 pub data: Vec<u8>,
140}
141
142#[derive(Clone, Debug, PartialEq, Eq, Hash)]
143pub struct CloudKitAtomicCreateBatch(String);
144
145impl CloudKitAtomicCreateBatch {
146 pub fn from_provider(value: String) -> Result<Self, CloudHomeError> {
147 if value.is_empty() {
148 return Err(CloudHomeError::Transport(
149 "CloudKit returned an empty atomic-create batch id".to_string(),
150 ));
151 }
152 Ok(Self(value))
153 }
154
155 pub fn as_provider(&self) -> &str {
156 &self.0
157 }
158}
159
160#[derive(Clone, Debug, PartialEq, Eq, Hash)]
161pub enum CloudKitScope {
162 Private,
163 Shared {
164 owner_name: String,
165 zone_name: String,
166 },
167}
168
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct CloudKitProviderIdentity {
171 pub container_id: String,
172 pub environment: coven_protocol::objects::CloudKitEnvironment,
173 pub owner_name: String,
174 pub zone_name: String,
175 pub current_user_record_name: String,
176}
177
178#[derive(Clone, Debug, PartialEq, Eq)]
179pub struct CloudKitShare {
180 pub share_url: String,
181 pub owner_name: String,
182 pub zone_name: String,
183}
184
185#[derive(Clone, Debug, PartialEq, Eq)]
186pub struct CloudKitAcceptedShareRecord {
187 pub share_record_name: String,
188 pub owner_name: String,
189 pub zone_name: String,
190 pub participant_record_name: String,
191 pub permission: CloudKitSharePermission,
192 pub acceptance: CloudKitShareAcceptance,
193 pub canonical_record: Vec<u8>,
194}
195
196#[derive(Clone, Debug, PartialEq, Eq)]
197pub enum CloudKitSharePermission {
198 ReadOnly,
199 ReadWrite,
200}
201
202#[derive(Clone, Debug, PartialEq, Eq)]
203pub enum CloudKitShareAcceptance {
204 Pending,
205 Accepted,
206}
207
208#[derive(Clone)]
210pub struct CloudKitCloudHome {
211 ops: Arc<dyn CloudKitOps>,
212 ids: IdRef,
213 scope: CloudKitScope,
214 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
215}
216
217impl CloudKitCloudHome {
218 pub fn new_private(
219 ops: Arc<dyn CloudKitOps>,
220 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
221 ) -> Self {
222 Self::new_private_with_ids(ops, Arc::new(UuidProvider), exact_upload_verification)
223 }
224
225 pub(crate) fn new_private_with_ids(
226 ops: Arc<dyn CloudKitOps>,
227 ids: IdRef,
228 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
229 ) -> Self {
230 Self {
231 ops,
232 ids,
233 scope: CloudKitScope::Private,
234 exact_upload_verification,
235 }
236 }
237
238 pub fn new_shared(
239 ops: Arc<dyn CloudKitOps>,
240 owner_name: String,
241 zone_name: String,
242 exact_upload_verification: coven_foundation::config::ExactUploadVerification,
243 ) -> Self {
244 Self {
245 ops,
246 ids: Arc::new(UuidProvider),
247 scope: CloudKitScope::Shared {
248 owner_name,
249 zone_name,
250 },
251 exact_upload_verification,
252 }
253 }
254
255 async fn begin_atomic_create(&self) -> Result<Arc<CloudKitStagingCleanup>, CloudHomeError> {
256 let ops = self.ops.clone();
257 let scope = self.scope.clone();
258 tokio::task::spawn_blocking(move || {
259 let batch = ops.begin_atomic_create(&scope)?;
260 Ok(Arc::new(CloudKitStagingCleanup::new(ops, scope, batch)))
261 })
262 .await
263 .map_err(|error| {
264 CloudHomeError::transport("run CloudKit atomic-create staging task", error)
265 })?
266 }
267
268 async fn settle_atomic_create_response_loss(
269 &self,
270 manifest_key: String,
271 ) -> Result<AtomicCreateReadback, CloudHomeError> {
272 let ops = self.ops.clone();
273 let scope = self.scope.clone();
274 blocking(
275 move || match ops.read_versioned_record(&scope, &manifest_key) {
276 Ok(record) => {
277 exact::decode_exact_manifest(&record.bytes)?;
278 Ok(AtomicCreateReadback::Created)
279 }
280 Err(CloudHomeError::NotFound(_)) => Ok(AtomicCreateReadback::Absent),
281 Err(error) => Err(error),
282 },
283 )
284 .await
285 }
286
287 async fn exact_manifest(
288 &self,
289 slot: &ObjectSlot,
290 ) -> Result<exact::ExactManifest, CloudHomeError> {
291 slot.require_logical_key_for("CloudKit")?;
292 let ops = self.ops.clone();
293 let scope = self.scope.clone();
294 let key = slot.logical_key().to_string();
295 blocking(move || {
296 let record = ops.read_versioned_record(&scope, &key)?;
297 exact::decode_exact_manifest(&record.bytes)
298 })
299 .await
300 }
301
302 async fn verify_exact_upload(
303 &self,
304 upload: &super::ExactUpload<'_>,
305 created_response_was_observed: bool,
306 ) -> Result<(), CloudHomeError> {
307 use coven_foundation::config::ExactUploadVerification;
308
309 match self.exact_upload_verification {
310 ExactUploadVerification::UploadChecksum => Err(CloudHomeError::Configuration(
311 "CloudKit does not accept a caller-supplied upload checksum".to_string(),
312 )),
313 ExactUploadVerification::MetadataHash => {
314 let manifest = self.exact_manifest(upload.object().slot()).await?;
315 if manifest.total_len as u64 != upload.object().stored_size()
316 || manifest.stored_hash != upload.object().stored_hash()
317 {
318 return Err(CloudHomeError::SlotCollision(
319 upload.object().slot().logical_key().to_string(),
320 ));
321 }
322 Ok(())
323 }
324 ExactUploadVerification::Readback => {
325 let ops = self.ops.clone();
326 let scope = self.scope.clone();
327 let key = upload.object().slot().logical_key().to_string();
328 let bytes = blocking(move || {
329 exact::read_exact_cloudkit_object(&*ops, &scope, &key).map(|value| value.0)
330 })
331 .await?;
332 upload.verify_stored_bytes(&bytes)
333 }
334 ExactUploadVerification::Unchecked => {
335 super::exact_upload::accept_unchecked_create_response(
336 created_response_was_observed,
337 upload.object(),
338 )
339 }
340 }
341 }
342}
343
344pub async fn accept_share(
345 ops: Arc<dyn CloudKitOps>,
346 share_url: String,
347) -> Result<CloudKitShare, CloudHomeError> {
348 blocking(move || ops.accept_share(&share_url)).await
349}
350
351async fn blocking<T, F>(f: F) -> Result<T, CloudHomeError>
356where
357 F: FnOnce() -> Result<T, CloudHomeError> + Send + 'static,
358 T: Send + 'static,
359{
360 tokio::task::spawn_blocking(f)
361 .await
362 .map_err(|e| CloudHomeError::transport("spawn_blocking failed".to_string(), e))?
363}
364
365struct BlockingState<T> {
366 result: std::sync::Mutex<Option<std::thread::Result<T>>>,
367 ready: std::sync::Condvar,
368 notify: tokio::sync::Notify,
369}
370
371struct BlockingCompletion<T> {
372 state: Arc<BlockingState<T>>,
373 consumed: bool,
374}
375
376impl<T> Drop for BlockingCompletion<T> {
377 fn drop(&mut self) {
378 if self.consumed {
379 return;
380 }
381 let mut result = self.state.result.lock().expect("lock blocking result");
382 while result.is_none() {
383 result = self
384 .state
385 .ready
386 .wait(result)
387 .expect("wait for blocking result");
388 }
389 }
390}
391
392async fn cancellation_safe_blocking<T, F>(f: F) -> Result<T, CloudHomeError>
393where
394 F: FnOnce() -> T + Send + 'static,
395 T: Send + 'static,
396{
397 let state = Arc::new(BlockingState {
398 result: std::sync::Mutex::new(None),
399 ready: std::sync::Condvar::new(),
400 notify: tokio::sync::Notify::new(),
401 });
402 let worker_state = state.clone();
403 tokio::task::spawn_blocking(move || {
404 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
405 worker_state
406 .result
407 .lock()
408 .expect("lock blocking result")
409 .replace(result);
410 worker_state.ready.notify_all();
411 worker_state.notify.notify_one();
412 });
413 let mut completion = BlockingCompletion {
414 state,
415 consumed: false,
416 };
417 let result = loop {
418 let notified = completion.state.notify.notified();
419 if let Some(result) = completion
420 .state
421 .result
422 .lock()
423 .expect("lock blocking result")
424 .take()
425 {
426 break result;
427 }
428 notified.await;
429 };
430 completion.consumed = true;
431 result.map_err(|_| CloudHomeError::Transport("CloudKit blocking task panicked".to_string()))
432}
433
434#[async_trait]
435impl CloudHome for CloudKitCloudHome {
436 async fn put_object(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
437 let ops = self.ops.clone();
438 let scope = self.scope.clone();
439 let k = key.to_string();
440 blocking(move || ops.write_record(&scope, &k, data)).await?;
441
442 let ops = self.ops.clone();
443 let scope = self.scope.clone();
444 let k = key.to_string();
445 blocking(move || delete_chunk_layout(&*ops, &scope, &k)).await
446 }
447
448 async fn open_multipart<'a>(
449 &'a self,
450 key: &str,
451 total_len: u64,
452 ) -> Result<super::BoxPartSink<'a>, CloudHomeError> {
453 let total_len = usize::try_from(total_len).map_err(|_| {
454 CloudHomeError::Transport(format!(
455 "CloudKit object {key} is too large for this platform"
456 ))
457 })?;
458 Ok(Box::new(CloudKitPartSink::new(
459 self.ops.clone(),
460 self.scope.clone(),
461 key.to_string(),
462 self.ids.new_id(),
463 total_len,
464 )))
465 }
466
467 fn multipart_threshold(&self) -> u64 {
468 CHUNK_SIZE as u64
469 }
470
471 async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
472 let ops = self.ops.clone();
473 let scope = self.scope.clone();
474 let key = key.to_string();
475 blocking(move || {
476 match ops.read_record(&scope, &key) {
477 Ok(data) => return Ok(data),
478 Err(CloudHomeError::NotFound(_)) => {}
479 Err(e) => return Err(e),
480 }
481
482 match ops.read_record(&scope, &chunk_manifest_key(&key)) {
483 Ok(data) => {
484 let manifest = decode_chunk_manifest(&data)?;
485 return read_chunked_object(&*ops, &scope, &key, manifest);
486 }
487 Err(CloudHomeError::NotFound(_)) => {}
488 Err(e) => return Err(e),
489 }
490
491 Err(missing_or_unassembled(&*ops, &scope, key))
492 })
493 .await
494 }
495
496 async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
497 if end <= start {
498 return Ok(Vec::new());
499 }
500
501 let ops = self.ops.clone();
502 let scope = self.scope.clone();
503 let key = key.to_string();
504 blocking(move || {
505 let start = start as usize;
506 let end = end as usize;
507
508 match ops.read_record(&scope, &key) {
509 Ok(data) => {
510 if end > data.len() {
511 return Err(CloudHomeError::Transport(format!(
512 "range {start}..{end} exceeds file size {}",
513 data.len()
514 )));
515 }
516 return Ok(data[start..end].to_vec());
517 }
518 Err(CloudHomeError::NotFound(_)) => {}
519 Err(e) => return Err(e),
520 }
521
522 match ops.read_record(&scope, &chunk_manifest_key(&key)) {
523 Ok(data) => {
524 let manifest = decode_chunk_manifest(&data)?;
525 let chunks = list_numbered_chunks(&*ops, &scope, &key, &manifest)?;
526 verify_chunk_manifest(&key, &manifest, &chunks)?;
527 if end > manifest.total_len {
528 return Err(CloudHomeError::Transport(format!(
529 "range {start}..{end} exceeds file size {}",
530 manifest.total_len
531 )));
532 }
533
534 let first_chunk = start / CHUNK_SIZE;
535 let last_chunk = (end - 1) / CHUNK_SIZE;
536 let mut result = Vec::with_capacity(end - start);
537 for (i, chunk_key) in chunks
538 .iter()
539 .filter(|(i, _)| (first_chunk..=last_chunk).contains(i))
540 {
541 let chunk = read_chunk(&*ops, &scope, &key, &manifest, *i, chunk_key)?;
542 let chunk_start = i * CHUNK_SIZE;
543 let slice_start = if *i == first_chunk {
544 start - chunk_start
545 } else {
546 0
547 };
548 let slice_end = if *i == last_chunk {
549 end - chunk_start
550 } else {
551 chunk.len()
552 };
553 result.extend_from_slice(&chunk[slice_start..slice_end]);
554 }
555 return Ok(result);
556 }
557 Err(CloudHomeError::NotFound(_)) => {}
558 Err(e) => return Err(e),
559 }
560
561 Err(missing_or_unassembled(&*ops, &scope, key))
562 })
563 .await
564 }
565
566 async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
567 let ops = self.ops.clone();
568 let scope = self.scope.clone();
569 let prefix = prefix.to_string();
570 blocking(move || {
571 let raw_keys = ops.list_records(&scope, &prefix)?;
572
573 let present: HashSet<&str> = raw_keys.iter().map(String::as_str).collect();
578 let mut base_keys: Vec<String> = raw_keys
579 .iter()
580 .map(|k| strip_part_suffix(k))
581 .filter(|&base| {
582 present.contains(base) || present.contains(chunk_manifest_key(base).as_str())
583 })
584 .map(str::to_string)
585 .collect();
586 base_keys.sort();
587 base_keys.dedup();
588 Ok(base_keys)
589 })
590 .await
591 }
592
593 async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
594 let ops = self.ops.clone();
595 let scope = self.scope.clone();
596 let key = key.to_string();
597 blocking(move || delete_all_variants(&*ops, &scope, &key)).await
598 }
599
600 async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
601 let ops = self.ops.clone();
602 let scope = self.scope.clone();
603 let key = key.to_string();
604 blocking(move || {
605 if ops.record_exists(&scope, &key)? {
606 return Ok(true);
607 }
608 let manifest = match ops.read_record(&scope, &chunk_manifest_key(&key)) {
609 Ok(data) => decode_chunk_manifest(&data)?,
610 Err(CloudHomeError::NotFound(_)) => return Ok(false),
611 Err(e) => return Err(e),
612 };
613 let chunks = list_numbered_chunks(&*ops, &scope, &key, &manifest)?;
614 Ok(verify_chunk_manifest(&key, &manifest, &chunks).is_ok())
615 })
616 .await
617 }
618
619 async fn set_access(
620 &self,
621 desired: CloudAccessState,
622 ) -> Result<CloudAccessOutcome, CloudHomeError> {
623 let ops = self.ops.clone();
624 match desired {
625 CloudAccessState::Present { member_pubkey, .. } => {
626 let lookup_ops = ops.clone();
629 let lookup_member = member_pubkey.clone();
630 let existing =
631 blocking(move || lookup_ops.share_for_member(&lookup_member)).await?;
632 let expected = match existing {
633 Some(share) => share,
634 None => {
635 let grant_ops = ops.clone();
636 let grant_member = member_pubkey.clone();
637 blocking(move || grant_ops.grant_share(&grant_member)).await?
638 }
639 };
640 let verified = blocking(move || ops.share_for_member(&member_pubkey))
641 .await?
642 .ok_or_else(|| {
643 CloudHomeError::Transport(
644 "CloudKit member share is absent after setting it present".to_string(),
645 )
646 })?;
647 if verified != expected {
648 return Err(CloudHomeError::Transport(
649 "CloudKit member share changed while verifying present access".to_string(),
650 ));
651 }
652 Ok(CloudAccessOutcome::Present(
653 CloudHomeJoinInfo::CloudKitShare {
654 share_url: verified.share_url,
655 owner_name: verified.owner_name,
656 zone_name: verified.zone_name,
657 },
658 ))
659 }
660 CloudAccessState::Absent { member_pubkey, .. } => {
661 let lookup_ops = ops.clone();
662 let lookup_member = member_pubkey.clone();
663 if blocking(move || lookup_ops.share_for_member(&lookup_member))
664 .await?
665 .is_some()
666 {
667 let revoke_ops = ops.clone();
668 let revoke_member = member_pubkey.clone();
669 blocking(move || revoke_ops.revoke_share(&revoke_member)).await?;
670 }
671 if blocking(move || ops.share_for_member(&member_pubkey))
672 .await?
673 .is_some()
674 {
675 return Err(CloudHomeError::Transport(
676 "CloudKit member share remains after setting access absent".to_string(),
677 ));
678 }
679 Ok(CloudAccessOutcome::Absent(RevokeOutcome::Revoked))
680 }
681 }
682 }
683}
684
685#[cfg(test)]
686mod tests;