Skip to main content

coven_storage/cloud/
cloudkit.rs

1//! CloudKit-backed `CloudHome` implementation.
2//!
3//! CloudKit's CKAsset has a 50MB limit, so large files are split into 10MB
4//! chunks stored as tokened part records plus a manifest record.
5//!
6//! The `CloudKitOps` trait defines synchronous record operations implemented by
7//! a host bridge to its CloudKit driver. `CloudKitCloudHome` wraps these ops,
8//! adds chunking logic, and implements `CloudHome`.
9
10use 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; // 10MB
26const 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
35/// Synchronous interface for raw CloudKit record operations.
36/// Implemented by a host bridge to its platform CloudKit driver.
37/// Methods block the calling thread while CloudKit async operations complete.
38pub trait CloudKitOps: Send + Sync {
39    /// Stable CloudKit namespace and principal facts for the selected zone.
40    fn provider_identity(
41        &self,
42        scope: &CloudKitScope,
43    ) -> Result<CloudKitProviderIdentity, CloudHomeError>;
44
45    /// Fetch the accepted CKShare for a shared scope and return its exact
46    /// canonical record bytes plus the participant facts verified by the host.
47    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    /// Read the exact CKRecord and return its opaque `recordChangeTag` with the bytes.
67    fn read_versioned_record(
68        &self,
69        scope: &CloudKitScope,
70        key: &str,
71    ) -> Result<CloudVersionedObject, CloudHomeError>;
72    /// Replace one record only while its CloudKit `recordChangeTag` equals
73    /// `expected`. The implementation must use CloudKit's server-side
74    /// unchanged-record save policy; a local fetch-and-check is insufficient.
75    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    /// Open a host-owned local staging batch. Staging never creates CloudKit
83    /// records; the host keeps payloads in temporary CKAsset files until commit.
84    fn begin_atomic_create(
85        &self,
86        scope: &CloudKitScope,
87    ) -> Result<CloudKitAtomicCreateBatch, CloudHomeError>;
88    /// Stage one bounded record payload in the host-owned batch.
89    fn stage_atomic_create_record(
90        &self,
91        scope: &CloudKitScope,
92        batch: &CloudKitAtomicCreateBatch,
93        record: CloudKitRecordCreate,
94    ) -> Result<(), CloudHomeError>;
95    /// Create every staged record as one atomic custom-zone modification. Every
96    /// record uses CloudKit's create-only save policy. A known precommit failure
97    /// leaves no record present. If the commit response is lost, the whole batch
98    /// may be present; preserve those records so the caller can read back every
99    /// requested key and settle the outcome.
100    /// Returned versions follow staging order when the response is received.
101    fn commit_atomic_create(
102        &self,
103        scope: &CloudKitScope,
104        batch: &CloudKitAtomicCreateBatch,
105    ) -> Result<Vec<CloudKitRecordVersion>, CloudHomeError>;
106    /// Discard host-local staging without deleting any CloudKit records the batch
107    /// may have committed. This is idempotent. On failure, return an error naming
108    /// the batch; the caller surfaces it and does not hide or retry it.
109    fn discard_atomic_create(
110        &self,
111        scope: &CloudKitScope,
112        batch: &CloudKitAtomicCreateBatch,
113    ) -> Result<(), CloudHomeError>;
114    /// Delete exactly these fetched record versions as one CloudKit atomic zone
115    /// modification. A changed or missing record fails the whole deletion.
116    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/// CloudKit-backed cloud home with automatic chunking for large files.
209#[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
351/// Run a synchronous CloudKit op on the blocking pool, mapping a join failure to a
352/// storage error. The Swift bridge methods block, so every `CloudHome` method
353/// wraps its call this way — one helper instead of the same `spawn_blocking(...)
354/// .await.map_err(...)` in each.
355async 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            // A base key exists only when its single record or its manifest is
574            // present — the manifest is what makes a chunked object readable. Part
575            // records with no manifest are an incomplete or aborted upload, which
576            // `read` cannot assemble, so they are not reported.
577            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                // CloudKit shares bind the joiner's identity at URL-accept time,
627                // so no provider email is required.
628                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;