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