Skip to main content

coven_core/sync/
cloud_storage.rs

1//! `SyncStorage` implementation backed by any `CloudHome`.
2//!
3//! Handles the cloud home path layout (where keys, heads, images, etc. live)
4//! and how objects are protected at rest. The underlying `CloudHome` only deals
5//! in raw bytes and flat keys; this layer applies the [`CloudCipher`] — sealing
6//! every object under the store key for an encrypted home, or storing it
7//! verbatim for a plaintext one — and drives the object-key suffix off the same
8//! choice (`.enc` for an encrypted home, no suffix for a plaintext one).
9
10use async_trait::async_trait;
11use std::path::Path;
12use std::sync::{Arc, RwLock};
13use tokio::sync::OnceCell;
14use tracing::warn;
15
16use super::storage::{
17    CoordinationError, CoordinationStorage, CreateHeadError, ExactObjectRef, PreparedExactObject,
18    ProtocolObjectContext, ProtocolObjectProtection, ReplaceHeadError, ResolvedProviderBinding,
19    StorageError, SyncStorage, VersionToken, VersionedObject,
20};
21use crate::encryption::{chunked_encrypted_len, EncryptionError, EncryptionService};
22use crate::keys::UserKeypair;
23use crate::storage::cloud::{
24    BlobBody, CloudFileReadError, CloudHeadStorage, CloudHome, ExactSlotStorage, ObjectSlot,
25};
26use crate::sync::store_commit::ObjectHash;
27
28/// Every encrypted object carries this cleartext prefix naming the key it was
29/// sealed under, by 8-byte fingerprint: magic, then the fingerprint. A read
30/// resolves that exact key from the keyring rather than trusting a generation
31/// number a fork could reuse.
32const KEY_TAG_MAGIC: &[u8; 4] = b"CKF1";
33const KEY_FINGERPRINT_LEN: usize = 8;
34const KEY_TAG_LEN: usize = KEY_TAG_MAGIC.len() + KEY_FINGERPRINT_LEN;
35
36/// How a cloud home protects its objects at rest. An `Encrypted` home seals
37/// every object under the store key (the default); a `Plaintext` home stores
38/// objects in the clear so the bucket is browsable, and drops the `.enc` suffix.
39#[derive(Clone)]
40pub enum CloudCipher {
41    Encrypted(EncryptionService),
42    Plaintext,
43}
44
45/// A sync session's fixed at-rest representation. The mode is selected once at
46/// construction: plaintext has no mutable key state, while encrypted sessions
47/// may merge new key generations without ever becoming plaintext.
48pub struct CloudCipherState {
49    mode: CloudCipherMode,
50}
51
52/// Read-only access to a session cipher snapshot. Production storage implements
53/// this with [`CloudCipherState`], whose mode cannot change. The test-utils
54/// implementation for a raw lock exists only for injected engine tests.
55pub trait CloudCipherAccess: Send + Sync {
56    fn snapshot(&self) -> CloudCipher;
57    fn merge_key_rotation(
58        &self,
59        new_encryption: &EncryptionService,
60        custody: &dyn crate::keys::MasterKeyCustody,
61    ) -> Result<Option<String>, crate::keys::KeyError>;
62}
63
64enum CloudCipherMode {
65    Encrypted(RwLock<EncryptionService>),
66    Plaintext,
67}
68
69impl CloudCipherState {
70    pub fn new(cipher: CloudCipher) -> Self {
71        let mode = match cipher {
72            CloudCipher::Encrypted(encryption) => {
73                CloudCipherMode::Encrypted(RwLock::new(encryption))
74            }
75            CloudCipher::Plaintext => CloudCipherMode::Plaintext,
76        };
77        Self { mode }
78    }
79
80    pub fn is_plaintext(&self) -> bool {
81        matches!(self.mode, CloudCipherMode::Plaintext)
82    }
83
84    pub fn encryption(&self) -> Option<EncryptionService> {
85        match &self.mode {
86            CloudCipherMode::Encrypted(encryption) => Some(encryption.read().unwrap().clone()),
87            CloudCipherMode::Plaintext => None,
88        }
89    }
90
91    pub(crate) fn snapshot(&self) -> CloudCipher {
92        match &self.mode {
93            CloudCipherMode::Encrypted(encryption) => {
94                CloudCipher::Encrypted(encryption.read().unwrap().clone())
95            }
96            CloudCipherMode::Plaintext => CloudCipher::Plaintext,
97        }
98    }
99
100    pub(crate) fn merge_key_rotation(
101        &self,
102        new_encryption: &EncryptionService,
103        custody: &dyn crate::keys::MasterKeyCustody,
104    ) -> Result<Option<String>, crate::keys::KeyError> {
105        let CloudCipherMode::Encrypted(live) = &self.mode else {
106            return Err(crate::keys::KeyError::Crypto(
107                "cannot rotate the key of a plaintext cloud home".to_string(),
108            ));
109        };
110        let mut live = live.write().unwrap();
111        let merged = live.merged_with(new_encryption);
112        if merged.key_count() == live.key_count() {
113            return Ok(None);
114        }
115        custody.persist(&crate::encryption::MasterKeyring::from(merged.clone()))?;
116        *live = merged;
117        Ok(Some(live.fingerprint()))
118    }
119}
120
121impl CloudCipherAccess for CloudCipherState {
122    fn snapshot(&self) -> CloudCipher {
123        CloudCipherState::snapshot(self)
124    }
125
126    fn merge_key_rotation(
127        &self,
128        new_encryption: &EncryptionService,
129        custody: &dyn crate::keys::MasterKeyCustody,
130    ) -> Result<Option<String>, crate::keys::KeyError> {
131        CloudCipherState::merge_key_rotation(self, new_encryption, custody)
132    }
133}
134
135impl CloudCipherAccess for Arc<CloudCipherState> {
136    fn snapshot(&self) -> CloudCipher {
137        self.as_ref().snapshot()
138    }
139
140    fn merge_key_rotation(
141        &self,
142        new_encryption: &EncryptionService,
143        custody: &dyn crate::keys::MasterKeyCustody,
144    ) -> Result<Option<String>, crate::keys::KeyError> {
145        self.as_ref().merge_key_rotation(new_encryption, custody)
146    }
147}
148
149#[cfg(any(test, feature = "test-utils"))]
150impl CloudCipherAccess for RwLock<CloudCipher> {
151    fn snapshot(&self) -> CloudCipher {
152        self.read().unwrap().clone()
153    }
154
155    fn merge_key_rotation(
156        &self,
157        new_encryption: &EncryptionService,
158        custody: &dyn crate::keys::MasterKeyCustody,
159    ) -> Result<Option<String>, crate::keys::KeyError> {
160        let mut cipher = self.write().unwrap();
161        let CloudCipher::Encrypted(live) = &mut *cipher else {
162            return Err(crate::keys::KeyError::Crypto(
163                "cannot rotate the key of a plaintext cloud home".to_string(),
164            ));
165        };
166        let merged = live.merged_with(new_encryption);
167        if merged.key_count() == live.key_count() {
168            return Ok(None);
169        }
170        custody.persist(&crate::encryption::MasterKeyring::from(merged.clone()))?;
171        *live = merged;
172        Ok(Some(live.fingerprint()))
173    }
174}
175
176/// The cloud has committed the store key to `committed_generation`, and this
177/// device's live cipher is still sealing under `live_generation`. A member
178/// removal rotates the store key on the cloud before this device necessarily
179/// folds the new generation into its own cipher (custody can fail to persist it
180/// locally even though the cloud rotation is durable), so the two can
181/// transiently disagree. Every seal for the cloud refuses while this holds,
182/// rather than sealing new data under a generation the store has already
183/// superseded — the removed member still holds a key for it.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
185#[error(
186    "the store key rotated to generation {committed_generation}, which this device has not \
187     adopted (still sealing under generation {live_generation}); refusing to seal for the \
188     cloud until adoption completes"
189)]
190pub struct RotationPending {
191    pub committed_generation: u64,
192    pub live_generation: u64,
193}
194
195/// Whether the cloud has committed the store key to a generation this device's
196/// live cipher has not adopted. Set when a member removal commits its rotation
197/// but this device's own custody fails to persist the new key locally, or when a
198/// peer's rotation is discovered on refresh and can't be adopted the same way;
199/// cleared only alongside the cipher swap that adopts it (see
200/// [`crate::sync::membership_ops::apply_key_rotation`]). `None` — the default —
201/// means the live cipher already holds everything the store has committed.
202///
203/// Shared (behind one `Arc`, via [`CloudSyncStorage::shared_pending_rotation`])
204/// across every path that seals data for the cloud — changesets, heads, blobs,
205/// tombstones, snapshots — so a rotation this device can't adopt blocks all of
206/// them the same way, not just the removal call that discovered it. This is the
207/// structural half of the invariant: this device must never seal under a
208/// generation the store has already superseded.
209pub struct PendingRotation(std::sync::RwLock<Option<u64>>);
210
211impl Default for PendingRotation {
212    fn default() -> Self {
213        Self(std::sync::RwLock::new(None))
214    }
215}
216
217impl PendingRotation {
218    pub fn none() -> Self {
219        Self::default()
220    }
221
222    /// Record that the cloud has committed `generation` and this device has not
223    /// folded it into its live cipher. Forward-only: a generation not newer than
224    /// one already recorded leaves the recorded value untouched, so an older
225    /// rediscovery (e.g. a decoy wrap from a non-rotating owner) can never erase
226    /// a genuinely newer generation already known to be pending.
227    pub fn mark_committed(&self, generation: u64) {
228        let mut recorded = self.0.write().unwrap();
229        if recorded.is_none_or(|g| generation > g) {
230            *recorded = Some(generation);
231        }
232    }
233
234    /// Clear the marker: the live cipher now holds everything committed.
235    pub fn clear(&self) {
236        *self.0.write().unwrap() = None;
237    }
238
239    /// Clear the mark only if `cipher`'s live seal key now covers the committed
240    /// generation; a higher generation still pending stays marked. The adoption
241    /// counterpart of [`Self::mark_committed`] — a merge that adopts a same- or
242    /// higher-generation key resolves the pause, but one that leaves a strictly
243    /// newer committed generation unadopted does not.
244    pub fn resolve(&self, cipher: &CloudCipher) {
245        if self.check(cipher).is_ok() {
246            self.clear();
247        }
248    }
249
250    /// The recorded committed generation, if any is pending — for status
251    /// reporting independent of a specific cipher snapshot.
252    pub fn pending_generation(&self) -> Option<u64> {
253        *self.0.read().unwrap()
254    }
255
256    /// Check `cipher` against the committed generation, if one is pending. A
257    /// plaintext home never rotates a store key (sharing, and hence removal,
258    /// requires an encrypted home), so it is never blocked.
259    pub fn check(&self, cipher: &CloudCipher) -> Result<(), RotationPending> {
260        let live_generation = match cipher {
261            CloudCipher::Encrypted(enc) => enc.current_generation(),
262            CloudCipher::Plaintext => return Ok(()),
263        };
264        if let Some(committed_generation) = self.pending_generation() {
265            if committed_generation > live_generation {
266                return Err(RotationPending {
267                    committed_generation,
268                    live_generation,
269                });
270            }
271        }
272        Ok(())
273    }
274}
275
276/// The `protocol_state` key under which a device durably records that a committed
277/// store-key rotation is outstanding (the committed generation as decimal). Set
278/// when [`PendingRotation`] is marked, deleted when the mark resolves. Restored
279/// into the in-memory marker at open so a restart cannot forget an unadopted
280/// rotation and resume sealing under the superseded generation — the removed
281/// member still holds a key for it — even if a fresh cloud scan, lagging, does
282/// not re-surface the rotation.
283pub const PENDING_ROTATION_STATE_KEY: &str = "pending_rotation_generation";
284
285/// Restore the in-memory [`PendingRotation`] from its durable `protocol_state`
286/// record, if one is set. Called at open, before the first cycle seals anything.
287pub async fn restore_pending_rotation(
288    db: &crate::database::Database,
289    pending_rotation: &PendingRotation,
290) -> Result<(), crate::database::DbError> {
291    if let Some(value) = db.get_protocol_state(PENDING_ROTATION_STATE_KEY).await? {
292        match value.parse::<u64>() {
293            Ok(generation) => pending_rotation.mark_committed(generation),
294            Err(_) => warn!("ignoring malformed persisted pending-rotation generation {value:?}"),
295        }
296    }
297    Ok(())
298}
299
300/// Write the in-memory [`PendingRotation`]'s current state to its durable
301/// `protocol_state` record: the committed generation while a rotation is pending, or
302/// a delete once it has resolved.
303pub async fn persist_pending_rotation(
304    db: &crate::database::Database,
305    pending_rotation: &PendingRotation,
306) -> Result<(), crate::database::DbError> {
307    match pending_rotation.pending_generation() {
308        Some(generation) => {
309            db.set_protocol_state(PENDING_ROTATION_STATE_KEY, &generation.to_string())
310                .await
311        }
312        None => db.delete_protocol_state(PENDING_ROTATION_STATE_KEY).await,
313    }
314}
315
316/// How a cloud home names its blob objects. Paired with the at-rest
317/// [`CloudCipher`] by the home's [`HomeStorage`](crate::config::HomeStorage): an
318/// opaque home is `Hashed` + encrypted, a browsable home is `Plain` + plaintext.
319#[derive(Clone, Copy)]
320pub enum BlobPathScheme {
321    /// Content-addressed shard `{namespace}/{ab}/{cd}/{id}` (an opaque home).
322    Hashed,
323    /// The consumer's own readable path, verbatim: `{namespace}/{cloud_path}`
324    /// (a browsable home). The consumer must supply `cloud_path` on every blob;
325    /// coven errors otherwise.
326    Plain,
327}
328
329impl BlobPathScheme {
330    /// The blob-path scheme a home's storage mode selects: an opaque home
331    /// obfuscates (`Hashed`), a browsable home is readable (`Plain`).
332    pub fn for_storage(storage: crate::config::HomeStorage) -> Self {
333        if storage.is_opaque() {
334            BlobPathScheme::Hashed
335        } else {
336            BlobPathScheme::Plain
337        }
338    }
339}
340
341impl CloudCipher {
342    /// The at-rest cipher a home's storage mode selects: an opaque home seals
343    /// under its store key (`Encrypted`), a browsable home stores in the clear
344    /// (`Plaintext`). The sibling of [`BlobPathScheme::for_storage`] — together
345    /// they map a [`HomeStorage`](crate::config::HomeStorage) to its
346    /// (path scheme, at-rest cipher) pair.
347    ///
348    /// `encryption` is the store master service; it is required for (and only
349    /// consulted on) an opaque home. `None` is returned only for an opaque home
350    /// with no service (a locked store) — a browsable home is always
351    /// `Plaintext` regardless. A host streaming a Remote blob via
352    /// [`BlobRangeReader`] builds the reader with this cipher so a read applies
353    /// the same protection the upload sealed under.
354    pub fn for_storage(
355        storage: crate::config::HomeStorage,
356        encryption: Option<EncryptionService>,
357    ) -> Option<Self> {
358        if storage.is_opaque() {
359            encryption.map(CloudCipher::Encrypted)
360        } else {
361            Some(CloudCipher::Plaintext)
362        }
363    }
364
365    /// Protect an immutable Store object or mutable membership/key object for
366    /// storage. Encrypted homes seal under the current store-key generation and
367    /// prefix that generation in cleartext; plaintext homes return the bytes
368    /// unchanged.
369    pub fn seal(&self, plaintext: Vec<u8>, aad_context: &[u8]) -> Vec<u8> {
370        // A control object is always whole-home scoped; only blobs carry a scope.
371        // This is exactly the master-scoped blob path: `encryption_for_scope`
372        // maps `Master` to the store key itself.
373        self.seal_scoped(crate::blob::BlobScope::Master, plaintext, aad_context)
374    }
375
376    /// Recover a control object read from storage. Inverse of [`Self::seal`].
377    pub fn open(&self, stored: Vec<u8>, aad_context: &[u8]) -> Result<Vec<u8>, EncryptionError> {
378        self.open_scoped(crate::blob::BlobScope::Master, stored, aad_context)
379    }
380
381    /// Protect a blob under its scope. Encrypted blobs carry the current
382    /// store-key generation in cleartext, so a later read knows which
383    /// generation to open with.
384    pub(crate) fn seal_scoped(
385        &self,
386        scope: crate::blob::BlobScope,
387        plaintext: Vec<u8>,
388        aad_context: &[u8],
389    ) -> Vec<u8> {
390        match self {
391            CloudCipher::Encrypted(e) => seal_scoped_encrypted(scope, e, &plaintext, aad_context),
392            CloudCipher::Plaintext => plaintext,
393        }
394    }
395
396    /// Recover a blob under its resolved scope. Inverse of [`Self::seal_scoped`].
397    pub(crate) fn open_scoped(
398        &self,
399        scope: crate::blob::BlobScope,
400        stored: Vec<u8>,
401        aad_context: &[u8],
402    ) -> Result<Vec<u8>, EncryptionError> {
403        match self {
404            CloudCipher::Encrypted(e) => open_scoped_encrypted(scope, e, &stored, aad_context),
405            CloudCipher::Plaintext => Ok(stored),
406        }
407    }
408
409    /// The object-key suffix this cipher implies: `.enc` for an encrypted home,
410    /// empty for a plaintext one. Note `"x".strip_suffix("")` returns `Some("x")`,
411    /// so the listing parsers strip an empty suffix as a clean no-op.
412    pub fn suffix(&self) -> &'static str {
413        match self {
414            CloudCipher::Encrypted(_) => ".enc",
415            CloudCipher::Plaintext => "",
416        }
417    }
418
419    /// Whether this is a plaintext (unencrypted) home.
420    pub fn is_plaintext(&self) -> bool {
421        matches!(self, CloudCipher::Plaintext)
422    }
423
424    /// The final object length for a blob of `plaintext_len` bytes under this
425    /// cipher: the generation tag plus the chunked-encrypted length for an
426    /// encrypted home, the plaintext length verbatim for a browsable one.
427    pub fn body_len(&self, plaintext_len: u64) -> u64 {
428        match self {
429            CloudCipher::Encrypted(_) => chunked_encrypted_len(plaintext_len) + KEY_TAG_LEN as u64,
430            CloudCipher::Plaintext => plaintext_len,
431        }
432    }
433
434    /// Open a streaming [`BlobBody`] over the local plaintext file at `file_path`,
435    /// sealing each chunk under `scope`'s key for an encrypted home or passing the
436    /// plaintext through for a browsable one — without ever reading or sealing the
437    /// whole blob into memory. The streaming sibling of [`seal_scoped`](Self::seal_scoped),
438    /// used by the upload drain.
439    pub(crate) async fn open_body(
440        &self,
441        scope: crate::blob::BlobScope,
442        file_path: &std::path::Path,
443        aad_context: &[u8],
444    ) -> Result<BlobBody, String> {
445        let plaintext_len = crate::local_blob::file_len(file_path).await?;
446        let reader = crate::local_blob::open_reader(file_path).await?;
447        let (sealer, prefix) = match self {
448            CloudCipher::Encrypted(e) => {
449                let (encryption, prefix) = sealing_encryption_for_scope(scope, e);
450                (Some(encryption.sealer(plaintext_len, aad_context)), prefix)
451            }
452            CloudCipher::Plaintext => (None, Vec::new()),
453        };
454        Ok(BlobBody::from_file_with_prefix(
455            self.body_len(plaintext_len),
456            reader,
457            sealer,
458            prefix,
459        ))
460    }
461}
462
463/// `SyncStorage` that delegates raw I/O to a `CloudHome` and handles the path
464/// layout and the at-rest protection (its [`CloudCipher`]).
465pub struct CloudSyncStorage {
466    /// The raw cloud backend. `Arc` (not `Box`) because a ranged read hands a
467    /// clone to the [`BlobRangeReader`] it builds — the reader holds the home for
468    /// the life of a stream and reads across awaits, so the home is genuinely
469    /// shared between this storage and the readers it spawns, not owned by one.
470    home: Arc<dyn CloudHome>,
471    exact: Arc<dyn ExactSlotStorage>,
472    exact_probe_peer: Arc<dyn ExactSlotStorage>,
473    cipher: Arc<CloudCipherState>,
474    /// Whether a committed rotation is outstanding — see [`PendingRotation`].
475    /// Shared the same way `cipher` is, so a member removal or a refresh cycle
476    /// that discovers a rotation this device can't adopt blocks every seal path,
477    /// not just the one that discovered it.
478    pending_rotation: Arc<PendingRotation>,
479    /// How blob objects are keyed. Unlike the cipher, the scheme does not rotate
480    /// over a home's life, so it is a plain field with no lock.
481    blob_paths: BlobPathScheme,
482    store_id: String,
483    coordination: Option<Arc<dyn CloudHeadStorage>>,
484    coordination_probe_peer: Option<Arc<dyn CloudHeadStorage>>,
485    /// The device's signing identity. The control objects this storage writes
486    /// (its head, the min_schema floor) are signed with it so a reader can
487    /// attribute and verify them; the at-rest cipher proves confidentiality, not
488    /// authorship.
489    keypair: UserKeypair,
490}
491
492impl CloudSyncStorage {
493    pub fn new(
494        home: Arc<dyn CloudHome>,
495        cipher: CloudCipher,
496        blob_paths: BlobPathScheme,
497        store_id: impl Into<String>,
498        keypair: UserKeypair,
499    ) -> Result<Self, crate::storage::cloud::CloudHomeError> {
500        let exact = home.clone().exact_slot_storage().ok_or_else(|| {
501            crate::storage::cloud::CloudHomeError::Configuration(
502                "CloudSyncStorage requires exact-slot storage".to_string(),
503            )
504        })?;
505        let exact_probe_peer = home.clone().exact_slot_storage().ok_or_else(|| {
506            crate::storage::cloud::CloudHomeError::Configuration(
507                "CloudSyncStorage requires a second exact-slot probe client".to_string(),
508            )
509        })?;
510        Ok(CloudSyncStorage {
511            home,
512            exact,
513            exact_probe_peer,
514            cipher: Arc::new(CloudCipherState::new(cipher)),
515            pending_rotation: Arc::new(PendingRotation::none()),
516            blob_paths,
517            store_id: store_id.into(),
518            keypair,
519            coordination: None,
520            coordination_probe_peer: None,
521        })
522    }
523
524    #[cfg(any(test, feature = "test-utils"))]
525    pub fn with_test_serial_coordination(
526        mut self,
527        coordination: Arc<dyn CloudHeadStorage>,
528    ) -> Self {
529        self.coordination = Some(coordination.clone());
530        self.coordination_probe_peer = Some(coordination);
531        self
532    }
533
534    pub fn with_serial_coordination_clients(
535        mut self,
536        coordination: Arc<dyn CloudHeadStorage>,
537        probe_peer: Arc<dyn CloudHeadStorage>,
538    ) -> Self {
539        self.coordination = Some(coordination);
540        self.coordination_probe_peer = Some(probe_peer);
541        self
542    }
543
544    pub fn serial_coordination(&self) -> Result<&dyn CoordinationStorage, CoordinationError> {
545        self.coordination.as_ref().ok_or_else(|| {
546            CoordinationError::Unavailable(
547                "configured storage adapter has no documented coordination capability".to_string(),
548            )
549        })?;
550        Ok(self)
551    }
552
553    pub(crate) fn serial_coordination_probe_clients(
554        &self,
555    ) -> Result<(CloudCoordinationClient<'_>, CloudCoordinationClient<'_>), CoordinationError> {
556        let primary = self.primary_coordination_client()?;
557        let peer = self.coordination_probe_peer.as_deref().ok_or_else(|| {
558            CoordinationError::Unavailable(
559                "Serial coordination probe peer is not configured".to_string(),
560            )
561        })?;
562        Ok((
563            primary,
564            CloudCoordinationClient {
565                storage: self,
566                raw: peer,
567            },
568        ))
569    }
570
571    pub(crate) fn exact_slot_probe_clients(
572        &self,
573    ) -> (&dyn ExactSlotStorage, &dyn ExactSlotStorage) {
574        (self.exact.as_ref(), self.exact_probe_peer.as_ref())
575    }
576
577    fn primary_coordination_client(
578        &self,
579    ) -> Result<CloudCoordinationClient<'_>, CoordinationError> {
580        let raw = self.coordination.as_deref().ok_or_else(|| {
581            CoordinationError::Unavailable(
582                "configured storage adapter has no documented coordination capability".to_string(),
583            )
584        })?;
585        Ok(CloudCoordinationClient { storage: self, raw })
586    }
587
588    pub(crate) fn blob_path_scheme(&self) -> BlobPathScheme {
589        self.blob_paths
590    }
591
592    pub(crate) fn store_id(&self) -> &str {
593        &self.store_id
594    }
595
596    fn validate_blob_locator_home(
597        &self,
598        locator: &crate::blob::locator::BlobLocator,
599    ) -> Result<(), StorageError> {
600        let valid = matches!(
601            (locator, self.blob_paths, self.cipher.is_plaintext()),
602            (
603                crate::blob::locator::BlobLocator::Opaque { .. },
604                BlobPathScheme::Hashed,
605                false
606            ) | (
607                crate::blob::locator::BlobLocator::Browsable { .. },
608                BlobPathScheme::Plain,
609                true
610            )
611        );
612        if !valid {
613            return Err(StorageError::InvalidContent(
614                "blob locator protection does not match the cloud home's fixed storage mode"
615                    .to_string(),
616            ));
617        }
618        Ok(())
619    }
620
621    async fn validate_blob_append_authority(
622        &self,
623        locator: &crate::blob::locator::BlobLocator,
624        authority: &crate::sync::storage::BlobWriteAuthority<'_>,
625    ) -> Result<(), StorageError> {
626        authority
627            .reference
628            .verify_registration(authority.registration)
629            .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
630        if locator.uploader() != authority.reference {
631            return Err(StorageError::InvalidContent(format!(
632                "blob locator uploader {:?} differs from its exact write authority",
633                locator.uploader()
634            )));
635        }
636        if authority.registration.author_pubkey != hex::encode(self.keypair.public_key()) {
637            return Err(StorageError::InvalidContent(
638                "blob write authority is not this device's identity key".to_string(),
639            ));
640        }
641        let live = self
642            .exact
643            .provider_binding()
644            .await
645            .map_err(StorageError::from)?;
646        if live.device != authority.registration.provider {
647            return Err(StorageError::InvalidContent(
648                "blob write authority differs from the authenticated provider principal"
649                    .to_string(),
650            ));
651        }
652        Ok(())
653    }
654
655    pub(crate) fn user_keypair(&self) -> &UserKeypair {
656        &self.keypair
657    }
658
659    /// The session's fixed-mode cipher state. The state exposes key-generation
660    /// merging but no operation that can replace encrypted mode with plaintext.
661    pub(crate) fn cipher_state(&self) -> &Arc<CloudCipherState> {
662        &self.cipher
663    }
664
665    /// Return a shared reference to the rotation-pending marker for external use
666    /// — the same instance a member removal (or a refresh cycle) marks when it
667    /// commits a rotation this device has not adopted, so every seal path (this
668    /// storage's own, plus the blob upload/tombstone drains, which seal directly
669    /// against a `CloudCipher` rather than through this trait) refuses together.
670    pub fn shared_pending_rotation(&self) -> Arc<PendingRotation> {
671        self.pending_rotation.clone()
672    }
673
674    /// Borrow the underlying CloudHome for direct access (e.g., grant_access/revoke_access).
675    pub fn cloud_home(&self) -> &dyn CloudHome {
676        &*self.home
677    }
678
679    fn cipher(&self) -> CloudCipher {
680        self.cipher.snapshot()
681    }
682
683    /// The object-key suffix the current cipher implies.
684    fn suffix(&self) -> &'static str {
685        self.cipher().suffix()
686    }
687
688    /// This device's hex public key — the `{uploader}` segment its own blob
689    /// uploads are keyed under. A device only ever writes blobs it authored, so a
690    /// write always keys under itself; a read resolves the uploader of the blob it
691    /// wants (which may be a peer) and passes it in.
692    pub(crate) fn self_uploader(&self) -> String {
693        hex::encode(self.keypair.public_key())
694    }
695
696    /// The cipher to seal new data under — refuses while the cloud has committed
697    /// a rotation this device has not adopted, rather than sealing under the
698    /// generation the store has superseded. Every write that protects data under
699    /// the store key calls this instead of reading `self.cipher()` directly;
700    /// reads/opens are unaffected (they resolve their own generation from the
701    /// ciphertext's tag) and keep reading the cipher plainly.
702    fn cipher_for_seal(&self) -> Result<CloudCipher, StorageError> {
703        let cipher = self.cipher();
704        self.pending_rotation.check(&cipher)?;
705        Ok(cipher)
706    }
707
708    fn protocol_cipher_for_seal(
709        &self,
710        context: &ProtocolObjectContext,
711    ) -> Result<CloudCipher, StorageError> {
712        match context.protection() {
713            ProtocolObjectProtection::Store => self.cipher_for_seal(),
714            ProtocolObjectProtection::Circle(encryption) => {
715                Ok(CloudCipher::Encrypted(encryption.clone()))
716            }
717            ProtocolObjectProtection::RecipientSealed => Ok(CloudCipher::Plaintext),
718        }
719    }
720
721    fn protocol_cipher_for_open(&self, context: &ProtocolObjectContext) -> CloudCipher {
722        match context.protection() {
723            ProtocolObjectProtection::Store => self.cipher(),
724            ProtocolObjectProtection::Circle(encryption) => {
725                CloudCipher::Encrypted(encryption.clone())
726            }
727            ProtocolObjectProtection::RecipientSealed => CloudCipher::Plaintext,
728        }
729    }
730
731    fn aad_context(&self, key: &str) -> Vec<u8> {
732        cloud_aad_context(&self.store_id, key)
733    }
734
735    /// The cloud object key for a blob under the home's [`BlobPathScheme`].
736    ///
737    /// **A cloud object is never rewritten with different bytes, so no two blobs ever
738    /// share a key.** `Hashed` gets that from the key itself; `Plain` gets it from the
739    /// blob's declared [`BlobReplacement`](crate::blob::BlobReplacement), which coven
740    /// enforces where a blob is derived from its row ([`crate::blob::decl::BlobDecls`]) —
741    /// a replaceable blob's readable path must name it, and a write-once blob's row can
742    /// never be repointed. Either way, an object's *presence* at a blob's key is proof of
743    /// its *content*, which is what lets the push skip an upload without asking a sealed
744    /// object what it holds.
745    ///
746    /// `Hashed` ignores `cloud_path` and shards by the id under the uploading
747    /// device: `{namespace}/{uploader}/{ab}/{cd}/{id}` — the id is right there, and the
748    /// `{uploader}` segment aligns the keyspace to the storage-access rule (a member
749    /// writes only under its own public key), so `uploader` is required and a missing one
750    /// is an error.
751    ///
752    /// `Plain` uses the consumer's `cloud_path` verbatim: `{namespace}/{cloud_path}`,
753    /// keeping the bucket browsable. Plain blob naming carries no uploader segment
754    /// and ignores `uploader`; the store still has membership authorization. A
755    /// `Plain` home with no `cloud_path` is an error — coven never silently falls
756    /// back to the hashed layout, which would scatter readable-path blobs under
757    /// unfindable shard keys.
758    pub fn blob_key(
759        scheme: BlobPathScheme,
760        namespace: &str,
761        uploader: Option<&str>,
762        id: &str,
763        cloud_path: Option<&str>,
764    ) -> Result<String, StorageError> {
765        match scheme {
766            BlobPathScheme::Hashed => {
767                let uploader = uploader.ok_or_else(|| {
768                    StorageError::Parse(format!(
769                        "an opaque-home blob requires an uploader for {namespace}/{id}"
770                    ))
771                })?;
772                Ok(crate::store_dir::StoreDir::uploader_hashed_key(
773                    namespace, uploader, id,
774                )?)
775            }
776            BlobPathScheme::Plain => {
777                let path = cloud_path.ok_or_else(|| {
778                    StorageError::Parse(format!(
779                        "unobfuscated blob-path home requires a cloud_path for blob {namespace}/{id}"
780                    ))
781                })?;
782                crate::store_dir::validate_path_token(namespace)?;
783                crate::store_dir::validate_cloud_path(path)?;
784                Ok(format!("{namespace}/{path}"))
785            }
786        }
787    }
788}
789
790pub(crate) struct CloudCoordinationClient<'a> {
791    storage: &'a CloudSyncStorage,
792    raw: &'a dyn CloudHeadStorage,
793}
794
795fn coordination_home_error(error: crate::storage::cloud::CloudHomeError) -> CoordinationError {
796    match error {
797        crate::storage::cloud::CloudHomeError::NotFound(key) => CoordinationError::NotFound(key),
798        crate::storage::cloud::CloudHomeError::Configuration(message) => {
799            CoordinationError::Unavailable(message)
800        }
801        crate::storage::cloud::CloudHomeError::Transport(message) => {
802            CoordinationError::Storage(message)
803        }
804        crate::storage::cloud::CloudHomeError::Io(error) => {
805            CoordinationError::Storage(format!("I/O error: {error}"))
806        }
807        error @ (crate::storage::cloud::CloudHomeError::CleanupFailed { .. }
808        | crate::storage::cloud::CloudHomeError::UnresolvedOutcome { .. })
809            if error.is_retryable() =>
810        {
811            CoordinationError::Storage(error.to_string())
812        }
813        crate::storage::cloud::CloudHomeError::AlreadyExists(key) => {
814            CoordinationError::Unavailable(format!("coordination object already exists: {key}"))
815        }
816        error @ (crate::storage::cloud::CloudHomeError::CleanupFailed { .. }
817        | crate::storage::cloud::CloudHomeError::UnresolvedOutcome { .. }) => {
818            CoordinationError::Unavailable(error.to_string())
819        }
820    }
821}
822
823/// The `EncryptionService` a blob's `scope` selects, against `master`: the
824/// store master itself, or a per-scope key derived from it. The blob storage
825/// methods and the outbox drain both turn a [`crate::blob::BlobScope`] into a
826/// key the same way, so they share this one mapping. Only an encrypted home has
827/// per-scope keys, so this is reached only from the [`CloudCipher::Encrypted`]
828/// branches.
829pub(crate) fn encryption_for_scope(
830    scope: crate::blob::BlobScope,
831    master: &EncryptionService,
832) -> EncryptionService {
833    match scope {
834        crate::blob::BlobScope::Master => master.clone(),
835        crate::blob::BlobScope::Derived(s) => master.derive_scoped(&s),
836    }
837}
838
839pub(crate) fn cloud_aad_context(store_id: &str, cloud_key: &str) -> Vec<u8> {
840    let mut context =
841        Vec::with_capacity(std::mem::size_of::<u64>() * 2 + store_id.len() + cloud_key.len());
842    context.extend_from_slice(&(store_id.len() as u64).to_le_bytes());
843    context.extend_from_slice(store_id.as_bytes());
844    context.extend_from_slice(&(cloud_key.len() as u64).to_le_bytes());
845    context.extend_from_slice(cloud_key.as_bytes());
846    context
847}
848
849fn protocol_object_aad_context(context: &ProtocolObjectContext, semantic_prefix: &str) -> Vec<u8> {
850    let domain = context.domain().aad_label();
851    let mut aad = Vec::with_capacity(
852        context.store_root_hash().as_bytes().len()
853            + std::mem::size_of::<u64>() * 2
854            + domain.len()
855            + semantic_prefix.len(),
856    );
857    aad.extend_from_slice(context.store_root_hash().as_bytes());
858    aad.extend_from_slice(&(domain.len() as u64).to_le_bytes());
859    aad.extend_from_slice(domain);
860    aad.extend_from_slice(&(semantic_prefix.len() as u64).to_le_bytes());
861    aad.extend_from_slice(semantic_prefix.as_bytes());
862    aad
863}
864
865fn key_tag(fingerprint: &[u8; KEY_FINGERPRINT_LEN]) -> Vec<u8> {
866    let mut tag = Vec::with_capacity(KEY_TAG_LEN);
867    tag.extend_from_slice(KEY_TAG_MAGIC);
868    tag.extend_from_slice(fingerprint);
869    tag
870}
871
872fn read_key_tag(stored: &[u8]) -> Result<([u8; KEY_FINGERPRINT_LEN], &[u8]), EncryptionError> {
873    if stored.len() < KEY_TAG_LEN {
874        return Err(EncryptionError::Decryption(
875            "ciphertext too short for key tag".to_string(),
876        ));
877    }
878    if &stored[..KEY_TAG_MAGIC.len()] != KEY_TAG_MAGIC {
879        return Err(EncryptionError::Decryption(
880            "ciphertext missing key tag".to_string(),
881        ));
882    }
883    let mut fingerprint = [0u8; KEY_FINGERPRINT_LEN];
884    fingerprint.copy_from_slice(&stored[KEY_TAG_MAGIC.len()..KEY_TAG_LEN]);
885    Ok((fingerprint, &stored[KEY_TAG_LEN..]))
886}
887
888/// The key `scope` seals under plus the cleartext key-tag prefix every encrypted
889/// object carries (the master seal key's fingerprint, so a later read resolves
890/// the exact key to open with — for a derived scope it re-derives from that
891/// master key).
892fn sealing_encryption_for_scope(
893    scope: crate::blob::BlobScope,
894    master: &EncryptionService,
895) -> (EncryptionService, Vec<u8>) {
896    (
897        encryption_for_scope(scope, master),
898        key_tag(&master.seal_fingerprint()),
899    )
900}
901
902fn opening_encryption_for_scope(
903    scope: crate::blob::BlobScope,
904    master: &EncryptionService,
905    fingerprint: &[u8; KEY_FINGERPRINT_LEN],
906) -> Result<EncryptionService, EncryptionError> {
907    match scope {
908        crate::blob::BlobScope::Master => master.service_for_fingerprint(fingerprint),
909        crate::blob::BlobScope::Derived(scope_id) => {
910            master.derive_scoped_for_fingerprint(fingerprint, &scope_id)
911        }
912    }
913}
914
915fn seal_scoped_encrypted(
916    scope: crate::blob::BlobScope,
917    master: &EncryptionService,
918    plaintext: &[u8],
919    aad_context: &[u8],
920) -> Vec<u8> {
921    let (encryption, mut prefix) = sealing_encryption_for_scope(scope, master);
922    prefix.extend(encryption.encrypt(plaintext, aad_context));
923    prefix
924}
925
926fn open_scoped_encrypted(
927    scope: crate::blob::BlobScope,
928    master: &EncryptionService,
929    stored: &[u8],
930    aad_context: &[u8],
931) -> Result<Vec<u8>, EncryptionError> {
932    let (fingerprint, ciphertext) = read_key_tag(stored)?;
933    opening_encryption_for_scope(scope, master, &fingerprint)?.decrypt(ciphertext, aad_context)
934}
935
936/// Reads plaintext byte ranges from a single stored blob without fetching the
937/// whole object — the ranged analogue of [`CloudSyncStorage::get_blob`].
938///
939/// On an encrypted home a blob is `[nonce: 24 bytes][encrypted chunks…]` (see
940/// [`EncryptionService::encrypt`]). Serving a plaintext range needs the nonce
941/// plus only the chunks covering it, never the whole object, so the 24-byte
942/// nonce is fetched once on the first read and reused: streaming a blob in N
943/// windows issues one nonce read, not N. On a plaintext home the blob is stored
944/// verbatim, so a range is read straight through with no nonce or decryption.
945///
946/// The blob's [`BlobScope`](crate::blob::BlobScope) is resolved to its
947/// key the same way `get_blob` resolves it (see [`encryption_for_scope`]), so a
948/// reader serves master- and derived-scoped blobs alike. A host that streams a
949/// large blob (audio playback, or pinning a file window by window) builds one of
950/// these instead of downloading and decrypting the whole object.
951pub struct BlobRangeReader {
952    home: Arc<dyn CloudHome>,
953    /// The scope's key for an encrypted home, resolved once at construction;
954    /// `None` for a plaintext home (the blob is read verbatim).
955    encryption: Option<RangeEncryption>,
956    /// The blob's cloud object key (see [`CloudSyncStorage::blob_key`]).
957    key: String,
958    /// Plaintext length of the blob. Ranges are validated against it, and the
959    /// encrypted chunk range is clamped to the matching blob length.
960    source_size: u64,
961    /// The encrypted blob header, read once on first use.
962    header: OnceCell<RangeHeader>,
963}
964
965enum ExactBlobOpening {
966    Browsable,
967    Opaque {
968        encryption: EncryptionService,
969        nonce: Vec<u8>,
970        next_chunk: u64,
971        aad_context: Vec<u8>,
972    },
973}
974
975/// Opens one already exact-verified stored blob and withholds EOF until the
976/// complete plaintext size and hash match the signed locator.
977struct ExactBlobPlaintextReader {
978    source: crate::local_blob::PlaintextReader,
979    opening: ExactBlobOpening,
980    remaining: u64,
981    total_size: u64,
982    hasher: Option<crate::blob::ContentHasher>,
983    expected_hash: ObjectHash,
984    locator_hash: ObjectHash,
985    pending: Vec<u8>,
986    pending_offset: usize,
987}
988
989impl ExactBlobPlaintextReader {
990    async fn new(
991        stored_file: &Path,
992        store_id: &str,
993        blob: &crate::blob::locator::StoredBlobRef,
994        protection: crate::sync::storage::BlobSpoolProtection,
995    ) -> Result<Self, StorageError> {
996        let locator = blob.locator();
997        let mut source = crate::local_blob::open_reader(stored_file)
998            .await
999            .map_err(StorageError::LocalFilesystem)?;
1000        let expected_stored_size = match locator {
1001            crate::blob::locator::BlobLocator::Opaque { .. } => {
1002                KEY_TAG_LEN as u64 + chunked_encrypted_len(locator.plaintext_size())
1003            }
1004            crate::blob::locator::BlobLocator::Browsable { .. } => locator.plaintext_size(),
1005        };
1006        if blob.object().stored_size() != expected_stored_size {
1007            return Err(StorageError::InvalidContent(format!(
1008                "blob {} stored length is {}, expected {expected_stored_size} for its locator",
1009                locator.locator_hash(),
1010                blob.object().stored_size()
1011            )));
1012        }
1013
1014        let opening = match (locator, protection) {
1015            (
1016                crate::blob::locator::BlobLocator::Opaque {
1017                    scope,
1018                    key_fingerprint,
1019                    ..
1020                },
1021                crate::sync::storage::BlobSpoolProtection::Opaque(master),
1022            ) => {
1023                let header = read_source_exact(
1024                    &mut source,
1025                    KEY_TAG_LEN + crate::encryption::NONCE_SIZE,
1026                    locator.locator_hash(),
1027                )
1028                .await?;
1029                let (fingerprint, nonce_and_chunks) = read_key_tag(&header).map_err(|error| {
1030                    StorageError::Decryption(format!(
1031                        "blob {} key tag: {error}",
1032                        locator.locator_hash()
1033                    ))
1034                })?;
1035                if crate::encryption::KeyFingerprint::from_bytes(fingerprint) != *key_fingerprint {
1036                    return Err(StorageError::InvalidContent(format!(
1037                        "blob {} stored key fingerprint differs from its locator",
1038                        locator.locator_hash()
1039                    )));
1040                }
1041                let encryption = opening_encryption_for_scope(scope.clone(), &master, &fingerprint)
1042                    .map_err(|error| {
1043                        StorageError::Decryption(format!(
1044                            "blob {} audience key: {error}",
1045                            locator.locator_hash()
1046                        ))
1047                    })?;
1048                ExactBlobOpening::Opaque {
1049                    encryption,
1050                    nonce: nonce_and_chunks.to_vec(),
1051                    next_chunk: 0,
1052                    aad_context: cloud_aad_context(store_id, &locator.semantic_key()),
1053                }
1054            }
1055            (
1056                crate::blob::locator::BlobLocator::Browsable { .. },
1057                crate::sync::storage::BlobSpoolProtection::Browsable,
1058            ) => ExactBlobOpening::Browsable,
1059            (crate::blob::locator::BlobLocator::Opaque { .. }, _) => {
1060                return Err(StorageError::Configuration(
1061                    "opaque blob locator requires audience encryption".to_string(),
1062                ));
1063            }
1064            (crate::blob::locator::BlobLocator::Browsable { .. }, _) => {
1065                return Err(StorageError::Configuration(
1066                    "browsable blob locator cannot use audience encryption".to_string(),
1067                ));
1068            }
1069        };
1070
1071        Ok(Self {
1072            source,
1073            opening,
1074            remaining: locator.plaintext_size(),
1075            total_size: locator.plaintext_size(),
1076            hasher: Some(crate::blob::ContentHasher::default()),
1077            expected_hash: locator.plaintext_hash(),
1078            locator_hash: locator.locator_hash(),
1079            pending: Vec::new(),
1080            pending_offset: 0,
1081        })
1082    }
1083
1084    fn take_pending(&mut self, max: usize) -> Vec<u8> {
1085        let end = (self.pending_offset + max).min(self.pending.len());
1086        let result = self.pending[self.pending_offset..end].to_vec();
1087        self.pending_offset = end;
1088        if self.pending_offset == self.pending.len() {
1089            self.pending.clear();
1090            self.pending_offset = 0;
1091        }
1092        result
1093    }
1094
1095    fn verify_complete(&mut self) -> Result<(), crate::local_blob::PlaintextChunkError> {
1096        let Some(hasher) = self.hasher.take() else {
1097            return Ok(());
1098        };
1099        let actual = hasher.finish();
1100        if actual != self.expected_hash.to_string() {
1101            return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1102                format!(
1103                    "blob {} plaintext hash mismatch: expected {}, got {actual}",
1104                    self.locator_hash, self.expected_hash
1105                ),
1106            ));
1107        }
1108        Ok(())
1109    }
1110}
1111
1112async fn read_source_exact(
1113    source: &mut crate::local_blob::PlaintextReader,
1114    len: usize,
1115    locator_hash: ObjectHash,
1116) -> Result<Vec<u8>, StorageError> {
1117    let mut bytes = Vec::with_capacity(len);
1118    while bytes.len() < len {
1119        let chunk = source
1120            .next_chunk(len - bytes.len())
1121            .await
1122            .map_err(StorageError::LocalFilesystem)?;
1123        if chunk.is_empty() {
1124            return Err(StorageError::InvalidContent(format!(
1125                "blob {locator_hash} stored body ended after {} of {len} required bytes",
1126                bytes.len()
1127            )));
1128        }
1129        bytes.extend_from_slice(&chunk);
1130    }
1131    Ok(bytes)
1132}
1133
1134#[async_trait]
1135impl crate::local_blob::PlaintextChunkReader for ExactBlobPlaintextReader {
1136    async fn next_chunk(
1137        &mut self,
1138        max: usize,
1139    ) -> Result<Vec<u8>, crate::local_blob::PlaintextChunkError> {
1140        if max == 0 {
1141            return Ok(Vec::new());
1142        }
1143        if !self.pending.is_empty() {
1144            return Ok(self.take_pending(max));
1145        }
1146        if self.remaining == 0 {
1147            self.verify_complete()?;
1148            return Ok(Vec::new());
1149        }
1150
1151        let plaintext = match &mut self.opening {
1152            ExactBlobOpening::Browsable => {
1153                let wanted = usize::try_from(self.remaining.min(max as u64)).map_err(|_| {
1154                    crate::local_blob::PlaintextChunkError::InvalidContent(
1155                        "blob plaintext read length does not fit this platform".to_string(),
1156                    )
1157                })?;
1158                let chunk = self.source.next_chunk(wanted).await.map_err(|error| {
1159                    crate::local_blob::PlaintextChunkError::Local(error.to_string())
1160                })?;
1161                if chunk.is_empty() {
1162                    return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1163                        format!("blob {} plaintext ended early", self.locator_hash),
1164                    ));
1165                }
1166                chunk
1167            }
1168            ExactBlobOpening::Opaque {
1169                encryption,
1170                nonce,
1171                next_chunk,
1172                aad_context,
1173            } => {
1174                let plaintext_len = self.remaining.min(crate::encryption::CHUNK_SIZE as u64);
1175                let encrypted_len = usize::try_from(plaintext_len)
1176                    .expect("one encryption chunk fits usize")
1177                    + crate::encryption::TAG_SIZE;
1178                let encrypted =
1179                    read_source_exact(&mut self.source, encrypted_len, self.locator_hash)
1180                        .await
1181                        .map_err(crate::local_blob::PlaintextChunkError::Remote)?;
1182                let start = *next_chunk * crate::encryption::CHUNK_SIZE as u64;
1183                let end = start + plaintext_len;
1184                let plaintext = encryption
1185                    .decrypt_range_with_offset(
1186                        nonce,
1187                        &encrypted,
1188                        *next_chunk,
1189                        start,
1190                        end,
1191                        self.total_size,
1192                        aad_context,
1193                    )
1194                    .map_err(|error| {
1195                        crate::local_blob::PlaintextChunkError::InvalidContent(format!(
1196                            "blob {} chunk {}: {error}",
1197                            self.locator_hash, *next_chunk
1198                        ))
1199                    })?;
1200                *next_chunk += 1;
1201                plaintext
1202            }
1203        };
1204        if plaintext.len() as u64 > self.remaining {
1205            return Err(crate::local_blob::PlaintextChunkError::InvalidContent(
1206                format!("blob {} produced excess plaintext", self.locator_hash),
1207            ));
1208        }
1209        self.hasher
1210            .as_mut()
1211            .expect("hash verification remains active until EOF")
1212            .update(&plaintext);
1213        self.remaining -= plaintext.len() as u64;
1214        self.pending = plaintext;
1215        Ok(self.take_pending(max))
1216    }
1217}
1218
1219/// What an encrypted home needs to open a blob's ranged reads: the master
1220/// service (which generation-resolves once the header's tag is read), the
1221/// blob's scope, and the AAD context.
1222struct RangeEncryption {
1223    master: EncryptionService,
1224    scope: crate::blob::BlobScope,
1225    aad_context: Vec<u8>,
1226}
1227
1228struct RangeHeader {
1229    encryption: EncryptionService,
1230    nonce: Vec<u8>,
1231    chunk_base: u64,
1232}
1233
1234impl BlobRangeReader {
1235    /// Build a reader for the blob stored at `key` (see
1236    /// [`CloudSyncStorage::blob_key`]), `source_size` plaintext bytes long.
1237    /// `cipher` and `scope` are how the home protects this blob: an encrypted
1238    /// home resolves `scope` to its key once here; a plaintext home ignores
1239    /// `scope` and reads verbatim.
1240    pub fn new(
1241        home: Arc<dyn CloudHome>,
1242        cipher: &CloudCipher,
1243        scope: crate::blob::BlobScope,
1244        key: String,
1245        source_size: u64,
1246        aad_context: Vec<u8>,
1247    ) -> Self {
1248        let encryption = match cipher {
1249            CloudCipher::Encrypted(master) => Some(RangeEncryption {
1250                master: master.clone(),
1251                scope,
1252                aad_context,
1253            }),
1254            CloudCipher::Plaintext => None,
1255        };
1256        BlobRangeReader {
1257            home,
1258            encryption,
1259            key,
1260            source_size,
1261            header: OnceCell::new(),
1262        }
1263    }
1264
1265    /// Read exactly `len` plaintext bytes starting at `offset`. An out-of-range
1266    /// request errors rather than truncating.
1267    pub async fn read(&self, offset: u64, len: u64) -> Result<Vec<u8>, StorageError> {
1268        if len == 0 {
1269            return Ok(Vec::new());
1270        }
1271        let end = offset.checked_add(len).ok_or_else(|| {
1272            StorageError::Storage(format!("blob range overflow: offset={offset}, len={len}"))
1273        })?;
1274        if end > self.source_size {
1275            return Err(StorageError::Storage(format!(
1276                "blob range {offset}..{end} exceeds blob size {}",
1277                self.source_size
1278            )));
1279        }
1280
1281        let encryption = match &self.encryption {
1282            Some(encryption) => encryption,
1283            // Plaintext home: the blob is stored verbatim, so the plaintext range
1284            // is exactly the stored byte range — no nonce, no chunking.
1285            None => {
1286                return self
1287                    .home
1288                    .read_range(&self.key, offset, end)
1289                    .await
1290                    .map_err(StorageError::from);
1291            }
1292        };
1293
1294        use crate::encryption::{chunked_encrypted_len, encrypted_chunk_range, CHUNK_SIZE};
1295
1296        let header = self.header(encryption).await?;
1297
1298        let (chunk_start, mut chunk_end) = encrypted_chunk_range(offset, end);
1299        chunk_end = chunk_end.min(chunked_encrypted_len(self.source_size));
1300        let stored_chunk_start =
1301            header.chunk_base + (chunk_start - crate::encryption::NONCE_SIZE as u64);
1302        let stored_chunk_end =
1303            header.chunk_base + (chunk_end - crate::encryption::NONCE_SIZE as u64);
1304        let encrypted_chunks = self
1305            .home
1306            .read_range(&self.key, stored_chunk_start, stored_chunk_end)
1307            .await
1308            .map_err(StorageError::from)?;
1309
1310        let first_chunk_index = offset / CHUNK_SIZE as u64;
1311        header
1312            .encryption
1313            .decrypt_range_with_offset(
1314                &header.nonce,
1315                &encrypted_chunks,
1316                first_chunk_index,
1317                offset,
1318                end,
1319                self.source_size,
1320                &encryption.aad_context,
1321            )
1322            .map_err(|e| StorageError::Decryption(format!("blob range {offset}..{end}: {e}")))
1323    }
1324
1325    /// The cached encrypted blob header, read once and reused for later range reads.
1326    async fn header(&self, encryption: &RangeEncryption) -> Result<&RangeHeader, StorageError> {
1327        use crate::encryption::NONCE_SIZE;
1328        self.header
1329            .get_or_try_init(|| async {
1330                let header = self
1331                    .home
1332                    .read_range(&self.key, 0, (KEY_TAG_LEN + NONCE_SIZE) as u64)
1333                    .await
1334                    .map_err(StorageError::from)?;
1335                if header.len() < KEY_TAG_LEN + NONCE_SIZE {
1336                    return Err(StorageError::Decryption(format!(
1337                        "blob header too short: expected {}, got {}",
1338                        KEY_TAG_LEN + NONCE_SIZE,
1339                        header.len()
1340                    )));
1341                }
1342                let (fingerprint, nonce_and_chunks) = read_key_tag(&header)
1343                    .map_err(|e| StorageError::Decryption(format!("blob key tag: {e}")))?;
1344                let service = opening_encryption_for_scope(
1345                    encryption.scope.clone(),
1346                    &encryption.master,
1347                    &fingerprint,
1348                )
1349                .map_err(|e| {
1350                    StorageError::Decryption(format!("blob key {}: {e}", hex::encode(fingerprint)))
1351                })?;
1352                Ok(RangeHeader {
1353                    encryption: service,
1354                    nonce: nonce_and_chunks[..NONCE_SIZE].to_vec(),
1355                    chunk_base: (KEY_TAG_LEN + NONCE_SIZE) as u64,
1356                })
1357            })
1358            .await
1359    }
1360}
1361
1362#[async_trait]
1363impl CoordinationStorage for CloudCoordinationClient<'_> {
1364    async fn provider_binding(&self) -> Result<ResolvedProviderBinding, CoordinationError> {
1365        SyncStorage::provider_binding(self.storage)
1366            .await
1367            .map_err(|error| CoordinationError::Storage(error.to_string()))
1368    }
1369
1370    async fn read_head(&self, key: &str) -> Result<VersionedObject, CoordinationError> {
1371        let raw = self.raw.read_head(key).await.map_err(|error| match error {
1372            crate::storage::cloud::CloudHomeError::NotFound(_) => {
1373                CoordinationError::NotFound(key.to_string())
1374            }
1375            other => coordination_home_error(other),
1376        })?;
1377        let bytes = self
1378            .storage
1379            .cipher()
1380            .open(raw.bytes, &self.storage.aad_context(key))
1381            .map_err(|error| CoordinationError::Open(error.to_string()))?;
1382        Ok(VersionedObject {
1383            bytes,
1384            version: VersionToken::from_cloud(raw.version),
1385        })
1386    }
1387
1388    async fn create_head(
1389        &self,
1390        key: &str,
1391        bytes: &[u8],
1392    ) -> Result<VersionedObject, CreateHeadError> {
1393        let stored = self
1394            .storage
1395            .cipher_for_seal()
1396            .map_err(|error| {
1397                CreateHeadError::Coordination(CoordinationError::Open(error.to_string()))
1398            })?
1399            .seal(bytes.to_vec(), &self.storage.aad_context(key));
1400        let created = self
1401            .raw
1402            .create_head(key, stored)
1403            .await
1404            .map_err(|error| match error {
1405                crate::storage::cloud::CloudHeadCreateError::AlreadyExists => {
1406                    CreateHeadError::AlreadyExists
1407                }
1408                crate::storage::cloud::CloudHeadCreateError::Storage(error) => {
1409                    CreateHeadError::Coordination(coordination_home_error(error))
1410                }
1411            })?;
1412        let opened = self
1413            .storage
1414            .cipher()
1415            .open(created.bytes, &self.storage.aad_context(key))
1416            .map_err(|error| {
1417                CreateHeadError::Coordination(CoordinationError::Open(error.to_string()))
1418            })?;
1419        if opened != bytes {
1420            return Err(CreateHeadError::Coordination(CoordinationError::Open(
1421                "created coordination head readback differs".to_string(),
1422            )));
1423        }
1424        Ok(VersionedObject {
1425            bytes: opened,
1426            version: VersionToken::from_cloud(created.version),
1427        })
1428    }
1429
1430    async fn replace_head(
1431        &self,
1432        key: &str,
1433        expected: &VersionToken,
1434        bytes: &[u8],
1435    ) -> Result<VersionedObject, ReplaceHeadError> {
1436        let stored = self
1437            .storage
1438            .cipher_for_seal()
1439            .map_err(|error| {
1440                ReplaceHeadError::Coordination(CoordinationError::Open(error.to_string()))
1441            })?
1442            .seal(bytes.to_vec(), &self.storage.aad_context(key));
1443        let replaced = self
1444            .raw
1445            .replace_head(key, expected.cloud(), stored)
1446            .await
1447            .map_err(|error| match error {
1448                crate::storage::cloud::CloudHeadReplaceError::VersionMismatch => {
1449                    ReplaceHeadError::VersionMismatch
1450                }
1451                crate::storage::cloud::CloudHeadReplaceError::Storage(error) => {
1452                    ReplaceHeadError::Coordination(coordination_home_error(error))
1453                }
1454            })?;
1455        let opened = self
1456            .storage
1457            .cipher()
1458            .open(replaced.bytes, &self.storage.aad_context(key))
1459            .map_err(|error| {
1460                ReplaceHeadError::Coordination(CoordinationError::Open(error.to_string()))
1461            })?;
1462        if opened != bytes {
1463            return Err(ReplaceHeadError::Coordination(CoordinationError::Open(
1464                "replaced coordination head readback differs".to_string(),
1465            )));
1466        }
1467        Ok(VersionedObject {
1468            bytes: opened,
1469            version: VersionToken::from_cloud(replaced.version),
1470        })
1471    }
1472
1473    async fn delete_probe_head(&self, key: &str) -> Result<(), CoordinationError> {
1474        self.raw
1475            .delete_probe_head(key)
1476            .await
1477            .map_err(coordination_home_error)
1478    }
1479}
1480
1481#[async_trait]
1482impl CoordinationStorage for CloudSyncStorage {
1483    async fn provider_binding(&self) -> Result<ResolvedProviderBinding, CoordinationError> {
1484        SyncStorage::provider_binding(self)
1485            .await
1486            .map_err(|error| CoordinationError::Storage(error.to_string()))
1487    }
1488
1489    async fn read_head(&self, key: &str) -> Result<VersionedObject, CoordinationError> {
1490        self.primary_coordination_client()?.read_head(key).await
1491    }
1492
1493    async fn create_head(
1494        &self,
1495        key: &str,
1496        bytes: &[u8],
1497    ) -> Result<VersionedObject, CreateHeadError> {
1498        self.primary_coordination_client()
1499            .map_err(CreateHeadError::Coordination)?
1500            .create_head(key, bytes)
1501            .await
1502    }
1503
1504    async fn replace_head(
1505        &self,
1506        key: &str,
1507        expected: &VersionToken,
1508        bytes: &[u8],
1509    ) -> Result<VersionedObject, ReplaceHeadError> {
1510        self.primary_coordination_client()
1511            .map_err(ReplaceHeadError::Coordination)?
1512            .replace_head(key, expected, bytes)
1513            .await
1514    }
1515
1516    async fn delete_probe_head(&self, key: &str) -> Result<(), CoordinationError> {
1517        self.primary_coordination_client()?
1518            .delete_probe_head(key)
1519            .await
1520    }
1521}
1522
1523#[async_trait]
1524impl SyncStorage for CloudSyncStorage {
1525    fn store_blob_protection(
1526        &self,
1527    ) -> Result<crate::sync::storage::BlobSpoolProtection, StorageError> {
1528        Ok(match self.cipher_for_seal()? {
1529            CloudCipher::Encrypted(encryption) => {
1530                crate::sync::storage::BlobSpoolProtection::Opaque(encryption)
1531            }
1532            CloudCipher::Plaintext => crate::sync::storage::BlobSpoolProtection::Browsable,
1533        })
1534    }
1535
1536    async fn provider_binding(&self) -> Result<ResolvedProviderBinding, StorageError> {
1537        self.exact.provider_binding().await.map_err(Into::into)
1538    }
1539
1540    async fn allocate_protocol_slot(
1541        &self,
1542        context: &ProtocolObjectContext,
1543        semantic_prefix: &str,
1544        extension: &str,
1545    ) -> Result<ObjectSlot, StorageError> {
1546        context.validate_path(semantic_prefix)?;
1547        context.validate_extension(extension)?;
1548        Ok(self
1549            .exact
1550            .allocate_slot(&format!("{semantic_prefix}{extension}"))
1551            .await?)
1552    }
1553
1554    fn prepare_protocol_object(
1555        &self,
1556        context: &ProtocolObjectContext,
1557        slot: ObjectSlot,
1558        semantic_prefix: &str,
1559        data: Vec<u8>,
1560    ) -> Result<PreparedExactObject, StorageError> {
1561        let expected = format!("{semantic_prefix}{}", context.domain().extension());
1562        if slot.logical_key() != expected {
1563            return Err(StorageError::Parse(format!(
1564                "protocol slot {:?} does not match semantic object {expected:?}",
1565                slot.logical_key()
1566            )));
1567        }
1568        let aad = protocol_object_aad_context(context, semantic_prefix);
1569        let stored = self.protocol_cipher_for_seal(context)?.seal(data, &aad);
1570        let reference = ExactObjectRef::new(
1571            slot,
1572            stored.len() as u64,
1573            crate::sync::store_commit::ObjectHash::digest(&stored),
1574        );
1575        PreparedExactObject::new(reference, stored)
1576    }
1577
1578    async fn create_protocol_object(
1579        &self,
1580        prepared: &PreparedExactObject,
1581    ) -> Result<(), StorageError> {
1582        let create_error = self
1583            .exact
1584            .create_at(
1585                prepared.reference().slot(),
1586                BlobBody::from_bytes(prepared.stored_bytes().to_vec()),
1587                &crate::storage::cloud::no_progress(),
1588            )
1589            .await
1590            .err();
1591        if let Some(error) = &create_error {
1592            if !matches!(
1593                error,
1594                crate::storage::cloud::CloudHomeError::AlreadyExists(_)
1595            ) && !error.is_retryable()
1596            {
1597                return Err(create_error.expect("create error exists").into());
1598            }
1599        }
1600        let observed = match self.exact.read_at(prepared.reference().slot()).await {
1601            Ok(observed) => observed,
1602            Err(crate::storage::cloud::CloudHomeError::NotFound(_)) if create_error.is_some() => {
1603                return Err(create_error.expect("create error exists").into())
1604            }
1605            Err(readback) => {
1606                return match create_error {
1607                    Some(operation) => Err(StorageError::UnresolvedOutcome {
1608                        operation: Box::new(operation.into()),
1609                        readback: Box::new(readback.into()),
1610                    }),
1611                    None => Err(readback.into()),
1612                }
1613            }
1614        };
1615        if observed != prepared.stored_bytes() {
1616            return Err(StorageError::SlotCollision(
1617                prepared.reference().slot().logical_key().to_string(),
1618            ));
1619        }
1620        prepared.reference().verify(&observed)?;
1621        Ok(())
1622    }
1623
1624    async fn read_protocol_object(
1625        &self,
1626        context: &ProtocolObjectContext,
1627        object: &ExactObjectRef,
1628        semantic_prefix: &str,
1629    ) -> Result<Vec<u8>, StorageError> {
1630        context.validate_reference(object, semantic_prefix)?;
1631        let stored = self.exact.read_at(object.slot()).await?;
1632        object.verify(&stored)?;
1633        let aad = protocol_object_aad_context(context, semantic_prefix);
1634        self.protocol_cipher_for_open(context)
1635            .open(stored, &aad)
1636            .map_err(|error| {
1637                StorageError::Decryption(format!(
1638                    "protocol object {}: {error}",
1639                    object.slot().logical_key()
1640                ))
1641            })
1642    }
1643
1644    async fn read_protocol_slot(
1645        &self,
1646        context: &ProtocolObjectContext,
1647        slot: &ObjectSlot,
1648        semantic_prefix: &str,
1649    ) -> Result<(Vec<u8>, ExactObjectRef), StorageError> {
1650        let (opened, prepared) = self
1651            .read_prepared_protocol_slot(context, slot, semantic_prefix)
1652            .await?;
1653        Ok((opened, prepared.reference().clone()))
1654    }
1655
1656    async fn read_prepared_protocol_slot(
1657        &self,
1658        context: &ProtocolObjectContext,
1659        slot: &ObjectSlot,
1660        semantic_prefix: &str,
1661    ) -> Result<(Vec<u8>, PreparedExactObject), StorageError> {
1662        context.validate_slot(slot, semantic_prefix)?;
1663        let stored = self.exact.read_at(slot).await?;
1664        let object = ExactObjectRef::new(
1665            slot.clone(),
1666            stored.len() as u64,
1667            crate::sync::store_commit::ObjectHash::digest(&stored),
1668        );
1669        let prepared = PreparedExactObject::new(object, stored.clone())?;
1670        let aad = protocol_object_aad_context(context, semantic_prefix);
1671        let opened = self
1672            .protocol_cipher_for_open(context)
1673            .open(stored, &aad)
1674            .map_err(|error| {
1675                StorageError::Decryption(format!("protocol object {}: {error}", slot.logical_key()))
1676            })?;
1677        Ok((opened, prepared))
1678    }
1679
1680    async fn delete_protocol_object(&self, object: &ExactObjectRef) -> Result<(), StorageError> {
1681        let delete_error = self.exact.delete_at(object.slot()).await.err();
1682        if delete_error
1683            .as_ref()
1684            .is_some_and(|error| !error.is_retryable())
1685        {
1686            return Err(delete_error.expect("delete error exists").into());
1687        }
1688        match self.exact.read_at(object.slot()).await {
1689            Err(crate::storage::cloud::CloudHomeError::NotFound(_)) => Ok(()),
1690            Err(readback) => match delete_error {
1691                Some(operation) => Err(StorageError::UnresolvedOutcome {
1692                    operation: Box::new(operation.into()),
1693                    readback: Box::new(readback.into()),
1694                }),
1695                None => Err(readback.into()),
1696            },
1697            Ok(_) => match delete_error {
1698                Some(error) => Err(error.into()),
1699                None => Err(StorageError::Storage(format!(
1700                    "exact object remains after delete: {}",
1701                    object.slot().logical_key()
1702                ))),
1703            },
1704        }
1705    }
1706
1707    async fn allocate_blob_slot(
1708        &self,
1709        locator: &crate::blob::locator::BlobLocator,
1710        authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1711    ) -> Result<ObjectSlot, StorageError> {
1712        self.validate_blob_locator_home(locator)?;
1713        self.validate_blob_append_authority(locator, authority)
1714            .await?;
1715        Ok(self.exact.allocate_slot(&locator.semantic_key()).await?)
1716    }
1717
1718    async fn seal_blob_to_spool(
1719        &self,
1720        locator: &crate::blob::locator::BlobLocator,
1721        authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1722        protection: crate::sync::storage::BlobSpoolProtection,
1723        plaintext_file: &Path,
1724        spool_file: &Path,
1725    ) -> Result<(), StorageError> {
1726        self.validate_blob_locator_home(locator)?;
1727        self.validate_blob_append_authority(locator, authority)
1728            .await?;
1729        let (plaintext_size, plaintext_hash) = crate::local_blob::exact_file_facts(plaintext_file)
1730            .await
1731            .map_err(StorageError::LocalFilesystem)?;
1732        if plaintext_size != locator.plaintext_size() || plaintext_hash != locator.plaintext_hash()
1733        {
1734            return Err(StorageError::InvalidContent(format!(
1735                "blob plaintext {}/{} does not match its locator size/hash",
1736                locator.namespace(),
1737                locator.blob_id()
1738            )));
1739        }
1740
1741        match tokio::fs::metadata(spool_file).await {
1742            Ok(metadata) => {
1743                if !metadata.is_file() {
1744                    return Err(StorageError::LocalFilesystem(format!(
1745                        "blob spool path is not a file: {}",
1746                        spool_file.display()
1747                    )));
1748                }
1749                let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(spool_file)
1750                    .await
1751                    .map_err(StorageError::LocalFilesystem)?;
1752                let object = ExactObjectRef::new(
1753                    ObjectSlot::logical(locator.semantic_key())?,
1754                    stored_size,
1755                    stored_hash,
1756                );
1757                let blob = crate::blob::locator::StoredBlobRef::new(locator.clone(), object)
1758                    .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1759                let mut reader =
1760                    ExactBlobPlaintextReader::new(spool_file, &self.store_id, &blob, protection)
1761                        .await?;
1762                loop {
1763                    let chunk =
1764                        crate::local_blob::PlaintextChunkReader::next_chunk(&mut reader, 1 << 20)
1765                            .await
1766                            .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1767                    if chunk.is_empty() {
1768                        break;
1769                    }
1770                }
1771                return Ok(());
1772            }
1773            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1774            Err(error) => {
1775                return Err(StorageError::LocalFilesystem(format!(
1776                    "inspect blob spool {}: {error}",
1777                    spool_file.display()
1778                )));
1779            }
1780        }
1781
1782        let retry_protection = protection.clone();
1783        let body = match (locator, protection) {
1784            (
1785                crate::blob::locator::BlobLocator::Opaque {
1786                    scope,
1787                    key_fingerprint,
1788                    ..
1789                },
1790                crate::sync::storage::BlobSpoolProtection::Opaque(encryption),
1791            ) => {
1792                if encryption.seal_key_fingerprint() != *key_fingerprint {
1793                    return Err(StorageError::InvalidContent(format!(
1794                        "blob locator key fingerprint {key_fingerprint} differs from the supplied audience key {}",
1795                        encryption.seal_key_fingerprint()
1796                    )));
1797                }
1798                let aad = cloud_aad_context(&self.store_id, &locator.semantic_key());
1799                CloudCipher::Encrypted(encryption)
1800                    .open_body(scope.clone(), plaintext_file, &aad)
1801                    .await
1802                    .map_err(StorageError::LocalFilesystem)?
1803            }
1804            (
1805                crate::blob::locator::BlobLocator::Browsable { .. },
1806                crate::sync::storage::BlobSpoolProtection::Browsable,
1807            ) => BlobBody::from_file(plaintext_file)
1808                .await
1809                .map_err(StorageError::LocalFilesystem)?,
1810            (crate::blob::locator::BlobLocator::Opaque { .. }, _) => {
1811                return Err(StorageError::Configuration(
1812                    "opaque blob locator requires audience encryption".to_string(),
1813                ));
1814            }
1815            (crate::blob::locator::BlobLocator::Browsable { .. }, _) => {
1816                return Err(StorageError::Configuration(
1817                    "browsable blob locator cannot use audience encryption".to_string(),
1818                ));
1819            }
1820        };
1821        let expected_size = body.len();
1822        let stream = futures_util::stream::try_unfold(body, |mut body| async move {
1823            match body.next_part(1 << 20).await? {
1824                Some(chunk) => Ok::<_, crate::storage::cloud::CloudHomeError>(Some((chunk, body))),
1825                None => Ok::<_, crate::storage::cloud::CloudHomeError>(None),
1826            }
1827        });
1828        let staged = crate::local_blob::stage_atomic_destination(spool_file)
1829            .await
1830            .map_err(StorageError::LocalFilesystem)?;
1831        let written = crate::local_blob::write_byte_stream_atomic(staged.path(), Box::pin(stream))
1832            .await
1833            .map_err(|error| match error {
1834                crate::local_blob::ByteStreamWriteError::Source(error) => error.into(),
1835                crate::local_blob::ByteStreamWriteError::Local(error) => {
1836                    StorageError::LocalFilesystem(error)
1837                }
1838            })?;
1839        if written != expected_size {
1840            return Err(StorageError::InvalidContent(format!(
1841                "blob spool {} contains {written} stored bytes, expected {expected_size}",
1842                spool_file.display()
1843            )));
1844        }
1845        match staged.commit_new().await {
1846            Ok(()) => Ok(()),
1847            Err(crate::local_blob::CommitNewFileError::DestinationExists(_)) => {
1848                let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(spool_file)
1849                    .await
1850                    .map_err(StorageError::LocalFilesystem)?;
1851                let object = ExactObjectRef::new(
1852                    ObjectSlot::logical(locator.semantic_key())?,
1853                    stored_size,
1854                    stored_hash,
1855                );
1856                let blob = crate::blob::locator::StoredBlobRef::new(locator.clone(), object)
1857                    .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1858                let mut reader = ExactBlobPlaintextReader::new(
1859                    spool_file,
1860                    &self.store_id,
1861                    &blob,
1862                    retry_protection,
1863                )
1864                .await?;
1865                loop {
1866                    let chunk =
1867                        crate::local_blob::PlaintextChunkReader::next_chunk(&mut reader, 1 << 20)
1868                            .await
1869                            .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
1870                    if chunk.is_empty() {
1871                        break;
1872                    }
1873                }
1874                Ok(())
1875            }
1876            Err(error) => Err(StorageError::LocalFilesystem(error.to_string())),
1877        }
1878    }
1879
1880    async fn prepare_blob_object(
1881        &self,
1882        locator: &crate::blob::locator::BlobLocator,
1883        authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1884        slot: ObjectSlot,
1885        stored_file: &Path,
1886    ) -> Result<crate::blob::locator::StoredBlobRef, StorageError> {
1887        self.validate_blob_locator_home(locator)?;
1888        self.validate_blob_append_authority(locator, authority)
1889            .await?;
1890        let expected = locator.semantic_key();
1891        if slot.logical_key() != expected {
1892            return Err(StorageError::Parse(format!(
1893                "blob slot {:?} does not match locator key {expected:?}",
1894                slot.logical_key()
1895            )));
1896        }
1897        let (stored_size, stored_hash) = crate::local_blob::exact_file_facts(stored_file)
1898            .await
1899            .map_err(StorageError::LocalFilesystem)?;
1900        crate::blob::locator::StoredBlobRef::new(
1901            locator.clone(),
1902            ExactObjectRef::new(slot, stored_size, stored_hash),
1903        )
1904        .map_err(|error| StorageError::InvalidContent(error.to_string()))
1905    }
1906
1907    async fn create_blob_object_from_file(
1908        &self,
1909        blob: &crate::blob::locator::StoredBlobRef,
1910        authority: &crate::sync::storage::BlobWriteAuthority<'_>,
1911        stored_file: &Path,
1912        progress: &crate::storage::cloud::UploadProgress<'_>,
1913    ) -> Result<(), StorageError> {
1914        let locator = blob.locator();
1915        let object = blob.object();
1916        self.validate_blob_locator_home(locator)?;
1917        self.validate_blob_append_authority(locator, authority)
1918            .await?;
1919        let expected = locator.semantic_key();
1920        if object.slot().logical_key() != expected {
1921            return Err(StorageError::Parse(format!(
1922                "blob object {:?} does not match locator key {expected:?}",
1923                object.slot().logical_key()
1924            )));
1925        }
1926        crate::local_blob::verify_exact_file(object, stored_file)
1927            .await
1928            .map_err(|error| match error {
1929                crate::local_blob::ExactFileVerificationError::Filesystem(error) => {
1930                    StorageError::LocalFilesystem(error)
1931                }
1932                crate::local_blob::ExactFileVerificationError::IdentityMismatch(error) => {
1933                    StorageError::InvalidContent(error)
1934                }
1935            })?;
1936        let body = BlobBody::from_file(stored_file)
1937            .await
1938            .map_err(StorageError::LocalFilesystem)?;
1939        let create_error = self
1940            .exact
1941            .create_at(object.slot(), body, progress)
1942            .await
1943            .err();
1944        if let Some(error) = &create_error {
1945            if !matches!(
1946                error,
1947                crate::storage::cloud::CloudHomeError::AlreadyExists(_)
1948            ) && !error.is_retryable()
1949            {
1950                return Err(create_error.expect("create error exists").into());
1951            }
1952        }
1953        match self.exact.read_at(object.slot()).await {
1954            Ok(stored) => object
1955                .verify(&stored)
1956                .map_err(|_| StorageError::SlotCollision(object.slot().logical_key().to_string())),
1957            Err(crate::storage::cloud::CloudHomeError::NotFound(_)) if create_error.is_some() => {
1958                Err(create_error.expect("create error exists").into())
1959            }
1960            Err(readback) => match create_error {
1961                Some(operation) => Err(StorageError::UnresolvedOutcome {
1962                    operation: Box::new(operation.into()),
1963                    readback: Box::new(readback.into()),
1964                }),
1965                None => Err(readback.into()),
1966            },
1967        }
1968    }
1969
1970    async fn verify_blob_object(
1971        &self,
1972        blob: &crate::blob::locator::StoredBlobRef,
1973    ) -> Result<(), StorageError> {
1974        self.validate_blob_locator_home(blob.locator())?;
1975        let expected = blob.locator().semantic_key();
1976        if blob.object().slot().logical_key() != expected {
1977            return Err(StorageError::Parse(format!(
1978                "blob object {:?} does not match locator key {expected:?}",
1979                blob.object().slot().logical_key()
1980            )));
1981        }
1982        let stored = self.exact.read_at(blob.object().slot()).await?;
1983        blob.object().verify(&stored)
1984    }
1985
1986    async fn stage_exact_blob_download(
1987        &self,
1988        blob: &crate::blob::locator::StoredBlobRef,
1989        dest: &Path,
1990    ) -> Result<crate::local_blob::AtomicStagedFile, StorageError> {
1991        let locator = blob.locator();
1992        let object = blob.object();
1993        self.validate_blob_locator_home(locator)?;
1994        let expected = locator.semantic_key();
1995        if object.slot().logical_key() != expected {
1996            return Err(StorageError::Parse(format!(
1997                "blob object {:?} does not match locator key {expected:?}",
1998                object.slot().logical_key()
1999            )));
2000        }
2001        let staged = crate::local_blob::stage_atomic_destination(dest)
2002            .await
2003            .map_err(StorageError::LocalFilesystem)?;
2004        self.exact
2005            .read_at_to_file(object.slot(), staged.path())
2006            .await
2007            .map_err(|error| match error {
2008                CloudFileReadError::Source(error) => StorageError::from(error),
2009                CloudFileReadError::Local(error) => StorageError::LocalFilesystem(error),
2010            })?;
2011        crate::local_blob::verify_exact_file(object, staged.path())
2012            .await
2013            .map_err(|error| match error {
2014                crate::local_blob::ExactFileVerificationError::Filesystem(error) => {
2015                    StorageError::LocalFilesystem(error)
2016                }
2017                crate::local_blob::ExactFileVerificationError::IdentityMismatch(error) => {
2018                    StorageError::InvalidContent(error)
2019                }
2020            })?;
2021        Ok(staged)
2022    }
2023
2024    async fn stage_verified_blob_plaintext(
2025        &self,
2026        blob: &crate::blob::locator::StoredBlobRef,
2027        protection: crate::sync::storage::BlobSpoolProtection,
2028        dest: &Path,
2029    ) -> Result<crate::local_blob::AtomicStagedFile, StorageError> {
2030        let stored_destination = dest.with_extension("coven-stored-download");
2031        let stored = self
2032            .stage_exact_blob_download(blob, &stored_destination)
2033            .await?;
2034        let plaintext = crate::local_blob::stage_atomic_destination(dest)
2035            .await
2036            .map_err(StorageError::LocalFilesystem)?;
2037        let mut reader =
2038            ExactBlobPlaintextReader::new(stored.path(), &self.store_id, blob, protection).await?;
2039        let written = crate::local_blob::write_stream_to_stage(&plaintext, &mut reader)
2040            .await
2041            .map_err(|error| match error {
2042                crate::local_blob::StreamWriteError::Source(
2043                    crate::local_blob::PlaintextChunkError::Remote(error),
2044                ) => error,
2045                crate::local_blob::StreamWriteError::Source(
2046                    crate::local_blob::PlaintextChunkError::InvalidContent(error),
2047                ) => StorageError::InvalidContent(error),
2048                crate::local_blob::StreamWriteError::Source(
2049                    crate::local_blob::PlaintextChunkError::Local(error),
2050                )
2051                | crate::local_blob::StreamWriteError::Local(error) => {
2052                    StorageError::LocalFilesystem(error)
2053                }
2054            })?;
2055        if written != blob.locator().plaintext_size() {
2056            return Err(StorageError::InvalidContent(format!(
2057                "blob {} plaintext stage contains {written} bytes, expected {}",
2058                blob.locator().locator_hash(),
2059                blob.locator().plaintext_size()
2060            )));
2061        }
2062        Ok(plaintext)
2063    }
2064
2065    async fn delete_blob_object(
2066        &self,
2067        blob: &crate::blob::locator::StoredBlobRef,
2068    ) -> Result<(), StorageError> {
2069        let locator = blob.locator();
2070        let object = blob.object();
2071        self.validate_blob_locator_home(locator)?;
2072        let expected = locator.semantic_key();
2073        if object.slot().logical_key() != expected {
2074            return Err(StorageError::Parse(format!(
2075                "blob object {:?} does not match locator key {expected:?}",
2076                object.slot().logical_key()
2077            )));
2078        }
2079        self.delete_protocol_object(object).await?;
2080        Ok(())
2081    }
2082
2083    async fn put_wrapped_key(
2084        &self,
2085        owner_pubkey: &str,
2086        recipient_pubkey: &str,
2087        data: Vec<u8>,
2088    ) -> Result<(), StorageError> {
2089        crate::store_dir::validate_path_token(owner_pubkey)?;
2090        let key = format!("keys/{owner_pubkey}/{recipient_pubkey}{}", self.suffix());
2091        // Wrapped keys are already sealed boxes; store as-is. The suffix is kept
2092        // uniform with the rest of the layout, but the bytes are never sealed by
2093        // the home cipher — wrapping a store key is meaningful only for a
2094        // shared (encrypted) home.
2095        self.home
2096            .write(
2097                &key,
2098                BlobBody::from_bytes(data),
2099                &crate::storage::cloud::no_progress(),
2100            )
2101            .await?;
2102        Ok(())
2103    }
2104
2105    async fn get_wrapped_key(
2106        &self,
2107        owner_pubkey: &str,
2108        recipient_pubkey: &str,
2109    ) -> Result<Vec<u8>, StorageError> {
2110        crate::store_dir::validate_path_token(owner_pubkey)?;
2111        let key = format!("keys/{owner_pubkey}/{recipient_pubkey}{}", self.suffix());
2112        // Wrapped keys are already sealed boxes; return as-is.
2113        self.home.read(&key).await.map_err(StorageError::from)
2114    }
2115
2116    async fn delete_wrapped_key(
2117        &self,
2118        owner_pubkey: &str,
2119        recipient_pubkey: &str,
2120    ) -> Result<(), StorageError> {
2121        crate::store_dir::validate_path_token(owner_pubkey)?;
2122        let key = format!("keys/{owner_pubkey}/{recipient_pubkey}{}", self.suffix());
2123        self.home.delete(&key).await?;
2124        Ok(())
2125    }
2126}
2127
2128#[cfg(test)]
2129mod tests {
2130    use super::*;
2131    use crate::blob::locator::{BlobLocator, RemoteAudience};
2132    use crate::blob::BlobScope;
2133    use crate::storage::cloud::test_utils::InMemoryCloudHome;
2134    use crate::sync::storage::{BlobWriteAuthority, ExactObjectRef};
2135    use crate::sync::store_commit::{
2136        DeviceStreamAnchor, ObjectHash, StoreCommitAnchor, StoreCreationId,
2137        StoreDeviceRegistration, StoreDeviceRegistrationOrigin, StoreDeviceRegistrationRef,
2138        StoreRootRef,
2139    };
2140    use crate::sync::test_helpers::open_serial_test_db;
2141
2142    async fn blob_write_registration(
2143        storage: &CloudSyncStorage,
2144        label: &str,
2145    ) -> (StoreDeviceRegistrationRef, StoreDeviceRegistration) {
2146        let root_bytes = format!("{label} Store root").into_bytes();
2147        let root = StoreRootRef {
2148            store_root_id: ObjectHash::digest(format!("{label} root id").as_bytes()),
2149            store_root_hash: ObjectHash::digest(&root_bytes),
2150            object: ExactObjectRef::new(
2151                ObjectSlot::logical(format!("store-v1/store-protocol-root/{label}.json")).unwrap(),
2152                root_bytes.len() as u64,
2153                ObjectHash::digest(&root_bytes),
2154            ),
2155        };
2156        let anchor_slot = |stream: &str| {
2157            ObjectSlot::logical(format!(
2158                "store-v1/test-device-streams/{label}/{stream}.json"
2159            ))
2160            .unwrap()
2161        };
2162        let provider = SyncStorage::provider_binding(storage).await.unwrap().device;
2163        let registration = StoreDeviceRegistration::signed(
2164            root,
2165            StoreDeviceRegistrationOrigin::Founder {
2166                creation_id: StoreCreationId::from_nonce(label),
2167            },
2168            provider,
2169            StoreCommitAnchor::MergeConcurrent {
2170                announcements: DeviceStreamAnchor::StoreAnnouncements {
2171                    first_slot: anchor_slot("announcements"),
2172                },
2173            },
2174            DeviceStreamAnchor::StoreAcknowledgements {
2175                first_slot: anchor_slot("acknowledgements"),
2176            },
2177            DeviceStreamAnchor::StoreSnapshots {
2178                first_slot: anchor_slot("snapshots"),
2179            },
2180            storage.user_keypair(),
2181        )
2182        .unwrap();
2183        let bytes = registration.to_bytes();
2184        let reference = StoreDeviceRegistrationRef::from_registration(
2185            &registration,
2186            ExactObjectRef::new(
2187                ObjectSlot::logical(format!(
2188                    "store-v1/devices/{}/registration.json",
2189                    registration.device_id
2190                ))
2191                .unwrap(),
2192                bytes.len() as u64,
2193                ObjectHash::digest(&bytes),
2194            ),
2195        );
2196        (reference, registration)
2197    }
2198
2199    #[tokio::test]
2200    async fn circle_blob_spool_uses_the_supplied_audience_key() {
2201        let home = InMemoryCloudHome::new();
2202        let identity = UserKeypair::generate();
2203        let storage = CloudSyncStorage::new(
2204            Arc::new(home),
2205            CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2206            BlobPathScheme::Hashed,
2207            "circle-blob-spool",
2208            identity,
2209        )
2210        .expect("test cloud storage supports exact slots");
2211        let (uploader, registration) = blob_write_registration(&storage, "circle-blob-spool").await;
2212        let authority = BlobWriteAuthority::new(&uploader, &registration).unwrap();
2213        let circle_key = EncryptionService::from_key([9u8; 32]);
2214        let plaintext = b"circle audience blob";
2215        let locator = BlobLocator::opaque(
2216            "covers",
2217            "circle-cover",
2218            uploader.clone(),
2219            RemoteAudience::Circle(crate::sync::circle::CircleId::from_bytes([8; 16])),
2220            BlobScope::Master,
2221            circle_key.seal_key_fingerprint(),
2222            plaintext.len() as u64,
2223            crate::sync::store_commit::ObjectHash::digest(plaintext),
2224        )
2225        .expect("build Circle locator");
2226        let temp = tempfile::tempdir().expect("temporary blob directory");
2227        let source = temp.path().join("plaintext");
2228        let spool = temp.path().join("spool");
2229        tokio::fs::write(&source, plaintext)
2230            .await
2231            .expect("write plaintext source");
2232
2233        storage
2234            .seal_blob_to_spool(
2235                &locator,
2236                &authority,
2237                crate::sync::storage::BlobSpoolProtection::Opaque(circle_key.clone()),
2238                &source,
2239                &spool,
2240            )
2241            .await
2242            .expect("seal Circle blob spool");
2243
2244        let stored = tokio::fs::read(&spool).await.expect("read exact spool");
2245        let opened = CloudCipher::Encrypted(circle_key)
2246            .open_scoped(
2247                BlobScope::Master,
2248                stored,
2249                &cloud_aad_context("circle-blob-spool", &locator.semantic_key()),
2250            )
2251            .expect("open Circle blob with supplied key");
2252        assert_eq!(opened, plaintext);
2253    }
2254
2255    #[tokio::test]
2256    async fn blob_spool_rejects_a_key_that_differs_from_the_locator() {
2257        let home = InMemoryCloudHome::new();
2258        let identity = UserKeypair::generate();
2259        let storage = CloudSyncStorage::new(
2260            Arc::new(home),
2261            CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2262            BlobPathScheme::Hashed,
2263            "blob-spool-key-mismatch",
2264            identity,
2265        )
2266        .expect("test cloud storage supports exact slots");
2267        let (uploader, registration) =
2268            blob_write_registration(&storage, "blob-spool-key-mismatch").await;
2269        let authority = BlobWriteAuthority::new(&uploader, &registration).unwrap();
2270        let declared_key = EncryptionService::from_key([9u8; 32]);
2271        let plaintext = b"audience blob";
2272        let locator = BlobLocator::opaque(
2273            "covers",
2274            "mismatched-cover",
2275            uploader.clone(),
2276            RemoteAudience::Store,
2277            BlobScope::Master,
2278            declared_key.seal_key_fingerprint(),
2279            plaintext.len() as u64,
2280            crate::sync::store_commit::ObjectHash::digest(plaintext),
2281        )
2282        .expect("build locator");
2283        let temp = tempfile::tempdir().expect("temporary blob directory");
2284        let source = temp.path().join("plaintext");
2285        let spool = temp.path().join("spool");
2286        tokio::fs::write(&source, plaintext)
2287            .await
2288            .expect("write plaintext source");
2289
2290        assert!(matches!(
2291            storage
2292                .seal_blob_to_spool(
2293                    &locator,
2294                    &authority,
2295                    crate::sync::storage::BlobSpoolProtection::Opaque(EncryptionService::from_key(
2296                        [10u8; 32]
2297                    ),),
2298                    &source,
2299                    &spool,
2300                )
2301                .await,
2302            Err(StorageError::InvalidContent(_))
2303        ));
2304        assert!(!spool.exists());
2305    }
2306
2307    #[tokio::test]
2308    async fn exact_blob_plaintext_is_published_only_after_both_verifications() {
2309        let home = InMemoryCloudHome::new();
2310        let identity = UserKeypair::generate();
2311        let storage = CloudSyncStorage::new(
2312            Arc::new(home),
2313            CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2314            BlobPathScheme::Hashed,
2315            "verified-blob-download",
2316            identity,
2317        )
2318        .expect("test cloud storage supports exact slots");
2319        let (uploader, registration) =
2320            blob_write_registration(&storage, "verified-blob-download").await;
2321        let authority = BlobWriteAuthority::new(&uploader, &registration).unwrap();
2322        let audience_key = EncryptionService::from_key([9u8; 32]);
2323        let plaintext: Vec<u8> = (0..150_000u32).map(|value| (value % 251) as u8).collect();
2324        let locator = BlobLocator::opaque(
2325            "audio",
2326            "verified-track",
2327            uploader.clone(),
2328            RemoteAudience::Store,
2329            BlobScope::Derived("album-a".to_string()),
2330            audience_key.seal_key_fingerprint(),
2331            plaintext.len() as u64,
2332            ObjectHash::digest(&plaintext),
2333        )
2334        .expect("build locator");
2335        let temp = tempfile::tempdir().expect("temporary blob directory");
2336        let source = temp.path().join("plaintext");
2337        let spool = temp.path().join("spool");
2338        let destination = temp.path().join("materialized");
2339        tokio::fs::write(&source, &plaintext)
2340            .await
2341            .expect("write plaintext source");
2342        storage
2343            .seal_blob_to_spool(
2344                &locator,
2345                &authority,
2346                crate::sync::storage::BlobSpoolProtection::Opaque(audience_key.clone()),
2347                &source,
2348                &spool,
2349            )
2350            .await
2351            .expect("seal exact spool");
2352        let slot = storage
2353            .allocate_blob_slot(&locator, &authority)
2354            .await
2355            .expect("allocate exact blob slot");
2356        let blob = storage
2357            .prepare_blob_object(&locator, &authority, slot, &spool)
2358            .await
2359            .expect("prepare exact blob");
2360        storage
2361            .create_blob_object_from_file(
2362                &blob,
2363                &authority,
2364                &spool,
2365                &crate::storage::cloud::no_progress(),
2366            )
2367            .await
2368            .expect("create exact blob");
2369
2370        let staged = storage
2371            .stage_verified_blob_plaintext(
2372                &blob,
2373                crate::sync::storage::BlobSpoolProtection::Opaque(audience_key),
2374                &destination,
2375            )
2376            .await
2377            .expect("stage verified plaintext");
2378        assert!(!destination.exists());
2379        assert_eq!(tokio::fs::read(staged.path()).await.unwrap(), plaintext);
2380        staged.commit().await.expect("publish verified plaintext");
2381        assert_eq!(tokio::fs::read(destination).await.unwrap(), plaintext);
2382    }
2383
2384    #[tokio::test]
2385    async fn stored_blob_corruption_never_creates_a_plaintext_stage() {
2386        let home = InMemoryCloudHome::new();
2387        let identity = UserKeypair::generate();
2388        let storage = CloudSyncStorage::new(
2389            Arc::new(home.clone()),
2390            CloudCipher::Encrypted(EncryptionService::from_key([3u8; 32])),
2391            BlobPathScheme::Hashed,
2392            "corrupt-blob-download",
2393            identity,
2394        )
2395        .expect("test cloud storage supports exact slots");
2396        let (uploader, registration) =
2397            blob_write_registration(&storage, "corrupt-blob-download").await;
2398        let authority = BlobWriteAuthority::new(&uploader, &registration).unwrap();
2399        let audience_key = EncryptionService::from_key([9u8; 32]);
2400        let plaintext = b"signed blob plaintext";
2401        let locator = BlobLocator::opaque(
2402            "covers",
2403            "corrupt-cover",
2404            uploader.clone(),
2405            RemoteAudience::Store,
2406            BlobScope::Master,
2407            audience_key.seal_key_fingerprint(),
2408            plaintext.len() as u64,
2409            ObjectHash::digest(plaintext),
2410        )
2411        .expect("build locator");
2412        let temp = tempfile::tempdir().expect("temporary blob directory");
2413        let source = temp.path().join("plaintext");
2414        let spool = temp.path().join("spool");
2415        let destination = temp.path().join("materialized");
2416        tokio::fs::write(&source, plaintext)
2417            .await
2418            .expect("write plaintext source");
2419        storage
2420            .seal_blob_to_spool(
2421                &locator,
2422                &authority,
2423                crate::sync::storage::BlobSpoolProtection::Opaque(audience_key.clone()),
2424                &source,
2425                &spool,
2426            )
2427            .await
2428            .expect("seal exact spool");
2429        let slot = storage
2430            .allocate_blob_slot(&locator, &authority)
2431            .await
2432            .unwrap();
2433        let blob = storage
2434            .prepare_blob_object(&locator, &authority, slot, &spool)
2435            .await
2436            .unwrap();
2437        storage
2438            .create_blob_object_from_file(
2439                &blob,
2440                &authority,
2441                &spool,
2442                &crate::storage::cloud::no_progress(),
2443            )
2444            .await
2445            .unwrap();
2446        home.replace_exact_object(blob.object().slot(), b"corrupt".to_vec());
2447
2448        assert!(matches!(
2449            storage
2450                .stage_verified_blob_plaintext(
2451                    &blob,
2452                    crate::sync::storage::BlobSpoolProtection::Opaque(audience_key),
2453                    &destination,
2454                )
2455                .await,
2456            Err(StorageError::InvalidContent(_))
2457        ));
2458        assert!(!destination.exists());
2459    }
2460
2461    #[tokio::test]
2462    async fn reserved_protocol_slot_read_returns_its_completed_exact_reference() {
2463        let home = InMemoryCloudHome::new();
2464        let storage = CloudSyncStorage::new(
2465            Arc::new(home),
2466            CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2467            BlobPathScheme::Hashed,
2468            "reserved-slot-read",
2469            UserKeypair::generate(),
2470        )
2471        .expect("test cloud storage supports exact slots");
2472        let root = crate::sync::store_commit::ObjectHash::digest(b"reserved slot root");
2473        let semantic = "store-v1/heads/device-a/1".to_string();
2474        let context = crate::sync::storage::ProtocolObjectContext::store(
2475            root,
2476            crate::sync::storage::ProtocolObjectDomain::StoreHead,
2477        );
2478        let slot = storage
2479            .allocate_protocol_slot(&context, &semantic, ".json")
2480            .await
2481            .expect("reserve successor slot");
2482        let prepared = storage
2483            .prepare_protocol_object(
2484                &context,
2485                slot.clone(),
2486                &semantic,
2487                b"signed successor bytes".to_vec(),
2488            )
2489            .expect("prepare successor bytes");
2490        storage
2491            .create_protocol_object(&prepared)
2492            .await
2493            .expect("create successor");
2494
2495        let (opened, completed) = storage
2496            .read_protocol_slot(&context, &slot, &semantic)
2497            .await
2498            .expect("read reserved successor slot");
2499
2500        assert_eq!(opened, b"signed successor bytes");
2501        assert_eq!(&completed, prepared.reference());
2502    }
2503
2504    #[tokio::test]
2505    async fn reserved_protocol_slot_rejects_a_mismatched_semantic_path_before_read() {
2506        let home = InMemoryCloudHome::new();
2507        let storage = CloudSyncStorage::new(
2508            Arc::new(home),
2509            CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
2510            BlobPathScheme::Hashed,
2511            "reserved-slot-relocation",
2512            UserKeypair::generate(),
2513        )
2514        .expect("test cloud storage supports exact slots");
2515        let root = crate::sync::store_commit::ObjectHash::digest(b"reserved slot root");
2516        let context = crate::sync::storage::ProtocolObjectContext::store(
2517            root,
2518            crate::sync::storage::ProtocolObjectDomain::StoreHead,
2519        );
2520        let original = "store-v1/heads/device-a/1".to_string();
2521        let relocated = "store-v1/heads/device-b/1".to_string();
2522        let slot = storage
2523            .allocate_protocol_slot(&context, &original, ".json")
2524            .await
2525            .expect("reserve successor slot");
2526
2527        assert!(matches!(
2528            storage
2529                .read_protocol_slot(&context, &slot, &relocated)
2530                .await,
2531            Err(StorageError::Parse(_))
2532        ));
2533    }
2534
2535    #[tokio::test]
2536    async fn protocol_object_read_rejects_root_domain_and_path_substitution() {
2537        let home = InMemoryCloudHome::new();
2538        let storage = CloudSyncStorage::new(
2539            Arc::new(home),
2540            CloudCipher::Encrypted(EncryptionService::from_key([8u8; 32])),
2541            BlobPathScheme::Hashed,
2542            "aad-store",
2543            UserKeypair::generate(),
2544        )
2545        .expect("test cloud storage supports immutable copies");
2546        let root = crate::sync::store_commit::ObjectHash::digest(b"root-a");
2547        let other_root = crate::sync::store_commit::ObjectHash::digest(b"root-b");
2548        let commit_hash = crate::sync::store_commit::ObjectHash::digest(b"commit");
2549        let family = crate::sync::store_commit::CandidateFamilyId::from_hash(
2550            crate::sync::store_commit::ObjectHash::digest(b"cloud test family"),
2551        );
2552        let semantic =
2553            crate::sync::store_commit::commit_semantic_prefix(family, "device", 1, commit_hash);
2554        let context = crate::sync::storage::ProtocolObjectContext::store(
2555            root,
2556            crate::sync::storage::ProtocolObjectDomain::StoreCommit,
2557        );
2558        let slot = storage
2559            .allocate_protocol_slot(&context, &semantic, ".json")
2560            .await
2561            .expect("allocate root-bound Store commit slot");
2562        let prepared = storage
2563            .prepare_protocol_object(&context, slot, &semantic, b"signed commit".to_vec())
2564            .expect("prepare root-bound Store commit");
2565        storage
2566            .create_protocol_object(&prepared)
2567            .await
2568            .expect("create root-bound Store commit");
2569        let object = prepared.reference().clone();
2570
2571        assert_eq!(
2572            storage
2573                .read_protocol_object(&context, &object, &semantic)
2574                .await
2575                .expect("read with the exact authenticated context"),
2576            b"signed commit",
2577        );
2578        let other_root_context = crate::sync::storage::ProtocolObjectContext::store(
2579            other_root,
2580            crate::sync::storage::ProtocolObjectDomain::StoreCommit,
2581        );
2582        assert!(matches!(
2583            storage
2584                .read_protocol_object(&other_root_context, &object, &semantic)
2585                .await,
2586            Err(crate::sync::storage::StorageError::Decryption(_))
2587        ));
2588
2589        let other_semantic =
2590            crate::sync::store_commit::commit_semantic_prefix(family, "device", 2, commit_hash);
2591        assert!(matches!(
2592            storage
2593                .read_protocol_object(&context, &object, &other_semantic)
2594                .await,
2595            Err(crate::sync::storage::StorageError::Parse(_))
2596        ));
2597
2598        let other_domain_context = crate::sync::storage::ProtocolObjectContext::store(
2599            root,
2600            crate::sync::storage::ProtocolObjectDomain::StoreHead,
2601        );
2602        assert!(matches!(
2603            storage
2604                .read_protocol_object(&other_domain_context, &object, &semantic)
2605                .await,
2606            Err(crate::sync::storage::StorageError::Parse(_))
2607        ));
2608    }
2609
2610    #[tokio::test]
2611    async fn coordination_probe_observes_one_create_and_replace_winner() {
2612        let home = InMemoryCloudHome::new();
2613        let first = CloudSyncStorage::new(
2614            Arc::new(home.clone()),
2615            CloudCipher::Plaintext,
2616            BlobPathScheme::Plain,
2617            "coordination-probe",
2618            UserKeypair::generate(),
2619        )
2620        .expect("test cloud storage supports immutable copies")
2621        .with_test_serial_coordination(Arc::new(home.clone()));
2622        let second = CloudSyncStorage::new(
2623            Arc::new(home.clone()),
2624            CloudCipher::Plaintext,
2625            BlobPathScheme::Plain,
2626            "coordination-probe",
2627            UserKeypair::generate(),
2628        )
2629        .expect("test cloud storage supports immutable copies")
2630        .with_test_serial_coordination(Arc::new(home.clone()));
2631
2632        let db = open_serial_test_db();
2633        let binding = SyncStorage::provider_binding(&first).await.unwrap();
2634        let probe_id = crate::sync::provider::ProviderProbeId::from_bytes([31; 32]);
2635        crate::sync::provider::probe_serial_coordination_receipt(
2636            first.serial_coordination().unwrap(),
2637            second.serial_coordination().unwrap(),
2638            &db,
2639            probe_id,
2640            &binding,
2641        )
2642        .await
2643        .expect("coordination race probe");
2644
2645        let key = format!(
2646            "__coven_probe__/serial/{}",
2647            hex::encode(probe_id.as_bytes())
2648        );
2649        assert!(home.get(&key).is_none());
2650        assert_eq!(home.deletes_seen(), vec![key]);
2651    }
2652
2653    #[test]
2654    fn storage_without_provider_capability_refuses_serial_coordination() {
2655        let storage = CloudSyncStorage::new(
2656            Arc::new(InMemoryCloudHome::new()),
2657            CloudCipher::Plaintext,
2658            BlobPathScheme::Plain,
2659            "unsupported-coordination",
2660            UserKeypair::generate(),
2661        )
2662        .expect("test cloud storage supports immutable copies");
2663
2664        assert!(matches!(
2665            storage.serial_coordination(),
2666            Err(CoordinationError::Unavailable(_))
2667        ));
2668    }
2669
2670    #[tokio::test]
2671    async fn coordination_probe_cleanup_failure_reports_remaining_key() {
2672        let home = InMemoryCloudHome::new();
2673        home.fail_coordination_probe_cleanup();
2674        let storage = CloudSyncStorage::new(
2675            Arc::new(home.clone()),
2676            CloudCipher::Plaintext,
2677            BlobPathScheme::Plain,
2678            "coordination-cleanup",
2679            UserKeypair::generate(),
2680        )
2681        .expect("test cloud storage supports immutable copies")
2682        .with_test_serial_coordination(Arc::new(home.clone()));
2683        let db = open_serial_test_db();
2684        let binding = SyncStorage::provider_binding(&storage).await.unwrap();
2685        let probe_id = crate::sync::provider::ProviderProbeId::from_bytes([37; 32]);
2686        let key = format!(
2687            "__coven_probe__/serial/{}",
2688            hex::encode(probe_id.as_bytes())
2689        );
2690
2691        let error = crate::sync::provider::probe_serial_coordination_receipt(
2692            storage.serial_coordination().unwrap(),
2693            storage.serial_coordination().unwrap(),
2694            &db,
2695            probe_id,
2696            &binding,
2697        )
2698        .await
2699        .expect_err("cleanup failure must abort the probe");
2700
2701        assert!(matches!(
2702            error,
2703            crate::sync::provider::ProviderProbeError::Storage(_)
2704        ));
2705        assert!(home.get(&key).is_some());
2706    }
2707}