Skip to main content

coven_core/sync/
storage.rs

1//! Exact storage access for signed protocol objects and stored blob bodies.
2//!
3//! Every remote object is addressed by an [`ExactObjectRef`]. The logical key
4//! supplies domain separation and the physical locator selects the one provider
5//! object whose stored size and hash the signed reference authenticates. Prefix
6//! enumeration and provider names never select protocol authority.
7use async_trait::async_trait;
8use std::path::Path;
9
10use crate::storage::cloud::{CloudHeadVersion, ObjectSlot};
11use crate::sync::store_commit::ObjectHash;
12
13/// Signed object kind bound into protection AAD and checked against the
14/// semantic path before storage I/O.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub(crate) enum ProtectedObjectDomain {
17    StoreProtocolRoot,
18    StoreCommit,
19    StoreHead,
20    StoreAck,
21    StoreDeviceRegistration,
22    StoreDeviceSelfRetirement,
23    DeviceJoinAttempt,
24    DeviceJoinOutcome,
25    DeviceJoinAbandonment,
26    DeviceJoinCleanupReceipt,
27    ProviderAccessGrant,
28    ProviderAccessWithdrawal,
29    OwnerRecoveryNode,
30    StoreSnapshotMeta,
31    StoreSnapshotImage,
32    StoreMembershipEntry,
33    StoreMembershipHead,
34    StoreMembershipResolution,
35    StorePackage,
36    CircleControl,
37    CircleRoster,
38    CircleRosterResolution,
39    CircleMetadata,
40    CirclePackage,
41    CircleAccessLeaf,
42    CircleAccessEnvelope,
43}
44
45#[derive(Clone, Copy)]
46struct ProtocolObjectMetadata {
47    aad_label: &'static [u8],
48    path: ProtocolPathRule,
49    extension: &'static str,
50}
51
52#[derive(Clone, Copy)]
53enum ProtocolPathRule {
54    Exact(&'static [ExactPathShape]),
55    StoreDeviceRegistration,
56    StoreMembershipHead,
57    StoreCandidate {
58        kind: &'static str,
59        component_count: usize,
60    },
61    CircleCandidate {
62        kind: &'static str,
63        component_count: usize,
64    },
65}
66
67#[derive(Clone, Copy)]
68struct ExactPathShape {
69    component_count: usize,
70    fixed_components: &'static [(usize, &'static str)],
71}
72
73impl ProtocolPathRule {
74    fn accepts(self, semantic_prefix: &str) -> bool {
75        match self {
76            Self::Exact(shapes) => shapes
77                .iter()
78                .any(|shape| accepts_path_shape(semantic_prefix, *shape)),
79            Self::StoreDeviceRegistration => {
80                (accepts_path_shape(
81                    semantic_prefix,
82                    ExactPathShape {
83                        component_count: 3,
84                        fixed_components: &[(0, "store-v1"), (1, "devices")],
85                    },
86                ) && semantic_prefix.split('/').nth(2) != Some("founder"))
87                    || accepts_path_shape(
88                        semantic_prefix,
89                        ExactPathShape {
90                            component_count: 5,
91                            fixed_components: &[
92                                (0, "store-v1"),
93                                (1, "devices"),
94                                (2, "founder"),
95                                (4, "registration"),
96                            ],
97                        },
98                    )
99            }
100            Self::StoreMembershipHead => {
101                (accepts_path_shape(
102                    semantic_prefix,
103                    ExactPathShape {
104                        component_count: 7,
105                        fixed_components: &[(0, "store-v1"), (1, "membership"), (2, "heads")],
106                    },
107                ) && semantic_prefix.split('/').nth(3) != Some("founder"))
108                    || accepts_path_shape(
109                        semantic_prefix,
110                        ExactPathShape {
111                            component_count: 6,
112                            fixed_components: &[
113                                (0, "store-v1"),
114                                (1, "membership"),
115                                (2, "heads"),
116                                (3, "founder"),
117                                (5, "1"),
118                            ],
119                        },
120                    )
121            }
122            Self::StoreCandidate {
123                kind,
124                component_count,
125            } => accepts_candidate_path(
126                semantic_prefix,
127                component_count,
128                &[(0, "store-v1"), (1, "candidates"), (3, kind)],
129            ),
130            Self::CircleCandidate {
131                kind,
132                component_count,
133            } => accepts_candidate_path(
134                semantic_prefix,
135                component_count,
136                &[(0, "circles"), (2, "candidates"), (4, kind)],
137            ),
138        }
139    }
140}
141
142fn accepts_path_shape(semantic_prefix: &str, shape: ExactPathShape) -> bool {
143    let components = semantic_prefix.split('/').collect::<Vec<_>>();
144    components.len() == shape.component_count
145        && components.iter().all(|component| !component.is_empty())
146        && shape
147            .fixed_components
148            .iter()
149            .all(|(index, expected)| components[*index] == *expected)
150}
151
152fn accepts_candidate_path(
153    semantic_prefix: &str,
154    component_count: usize,
155    fixed_components: &[(usize, &str)],
156) -> bool {
157    let components = semantic_prefix.split('/').collect::<Vec<_>>();
158    components.len() == component_count
159        && components.iter().all(|component| !component.is_empty())
160        && fixed_components.iter().all(|(index, expected)| {
161            components[*index] == *expected
162                && components
163                    .iter()
164                    .filter(|component| **component == *expected)
165                    .count()
166                    == 1
167        })
168}
169
170impl ProtectedObjectDomain {
171    fn metadata(self) -> ProtocolObjectMetadata {
172        match self {
173            Self::StoreProtocolRoot => ProtocolObjectMetadata {
174                aad_label: b"store-protocol-root",
175                path: ProtocolPathRule::Exact(&[ExactPathShape {
176                    component_count: 2,
177                    fixed_components: &[(0, "store-v1"), (1, "store-protocol-root")],
178                }]),
179                extension: ".json",
180            },
181            Self::StoreCommit => ProtocolObjectMetadata {
182                aad_label: b"store-commit",
183                path: ProtocolPathRule::StoreCandidate {
184                    kind: "commits",
185                    component_count: 7,
186                },
187                extension: ".json",
188            },
189            Self::StoreHead => ProtocolObjectMetadata {
190                aad_label: b"store-head",
191                path: ProtocolPathRule::Exact(&[ExactPathShape {
192                    component_count: 4,
193                    fixed_components: &[(0, "store-v1"), (1, "heads")],
194                }]),
195                extension: ".json",
196            },
197            Self::StoreAck => ProtocolObjectMetadata {
198                aad_label: b"store-ack",
199                path: ProtocolPathRule::Exact(&[ExactPathShape {
200                    component_count: 4,
201                    fixed_components: &[(0, "store-v1"), (1, "acks")],
202                }]),
203                extension: ".json",
204            },
205            Self::StoreDeviceRegistration => ProtocolObjectMetadata {
206                aad_label: b"store-device-registration",
207                path: ProtocolPathRule::StoreDeviceRegistration,
208                extension: ".json",
209            },
210            Self::StoreDeviceSelfRetirement => ProtocolObjectMetadata {
211                aad_label: b"store-device-self-retirement",
212                path: ProtocolPathRule::StoreCandidate {
213                    kind: "device-self-retirements",
214                    component_count: 6,
215                },
216                extension: ".json",
217            },
218            Self::DeviceJoinAttempt => ProtocolObjectMetadata {
219                aad_label: b"device-join-attempt",
220                path: ProtocolPathRule::Exact(&[ExactPathShape {
221                    component_count: 3,
222                    fixed_components: &[(0, "store-v1"), (1, "device-join-attempts")],
223                }]),
224                extension: ".json",
225            },
226            Self::DeviceJoinOutcome => ProtocolObjectMetadata {
227                aad_label: b"device-join-outcome",
228                path: ProtocolPathRule::Exact(&[ExactPathShape {
229                    component_count: 3,
230                    fixed_components: &[(0, "store-v1"), (1, "device-join-outcomes")],
231                }]),
232                extension: ".json",
233            },
234            Self::DeviceJoinAbandonment => ProtocolObjectMetadata {
235                aad_label: b"device-join-abandonment",
236                path: ProtocolPathRule::Exact(&[ExactPathShape {
237                    component_count: 3,
238                    fixed_components: &[(0, "store-v1"), (1, "device-join-attempts")],
239                }]),
240                extension: ".json",
241            },
242            Self::DeviceJoinCleanupReceipt => ProtocolObjectMetadata {
243                aad_label: b"device-join-cleanup-receipt",
244                path: ProtocolPathRule::Exact(&[ExactPathShape {
245                    component_count: 3,
246                    fixed_components: &[(0, "store-v1"), (1, "device-join-cleanup-receipts")],
247                }]),
248                extension: ".json",
249            },
250            Self::ProviderAccessGrant => ProtocolObjectMetadata {
251                aad_label: b"provider-access-grant",
252                path: ProtocolPathRule::Exact(&[ExactPathShape {
253                    component_count: 4,
254                    fixed_components: &[(0, "store-v1"), (1, "provider-access"), (2, "grants")],
255                }]),
256                extension: ".json",
257            },
258            Self::ProviderAccessWithdrawal => ProtocolObjectMetadata {
259                aad_label: b"provider-access-withdrawal",
260                path: ProtocolPathRule::Exact(&[ExactPathShape {
261                    component_count: 4,
262                    fixed_components: &[
263                        (0, "store-v1"),
264                        (1, "provider-access"),
265                        (2, "withdrawals"),
266                    ],
267                }]),
268                extension: ".json",
269            },
270            Self::OwnerRecoveryNode => ProtocolObjectMetadata {
271                aad_label: b"owner-recovery-node",
272                path: ProtocolPathRule::Exact(&[ExactPathShape {
273                    component_count: 5,
274                    fixed_components: &[(0, "store-v1"), (1, "recovery")],
275                }]),
276                extension: ".json",
277            },
278            Self::StoreSnapshotMeta => ProtocolObjectMetadata {
279                aad_label: b"store-snapshot-meta",
280                path: ProtocolPathRule::Exact(&[ExactPathShape {
281                    component_count: 4,
282                    fixed_components: &[(0, "store-v1"), (1, "snapshots")],
283                }]),
284                extension: ".json",
285            },
286            Self::StoreSnapshotImage => ProtocolObjectMetadata {
287                aad_label: b"store-snapshot-image",
288                path: ProtocolPathRule::Exact(&[ExactPathShape {
289                    component_count: 4,
290                    fixed_components: &[(0, "store-v1"), (1, "snapshot-images")],
291                }]),
292                extension: ".db",
293            },
294            Self::StoreMembershipEntry => ProtocolObjectMetadata {
295                aad_label: b"store-membership-entry",
296                path: ProtocolPathRule::Exact(&[ExactPathShape {
297                    component_count: 8,
298                    fixed_components: &[(0, "store-v1"), (1, "membership"), (2, "entries")],
299                }]),
300                extension: ".json",
301            },
302            Self::StoreMembershipHead => ProtocolObjectMetadata {
303                aad_label: b"store-membership-head",
304                path: ProtocolPathRule::StoreMembershipHead,
305                extension: ".json",
306            },
307            Self::StoreMembershipResolution => ProtocolObjectMetadata {
308                aad_label: b"store-membership-resolution",
309                path: ProtocolPathRule::Exact(&[ExactPathShape {
310                    component_count: 6,
311                    fixed_components: &[(0, "store-v1"), (1, "membership"), (2, "resolutions")],
312                }]),
313                extension: ".json",
314            },
315            Self::StorePackage => ProtocolObjectMetadata {
316                aad_label: b"store-package",
317                path: ProtocolPathRule::StoreCandidate {
318                    kind: "packages",
319                    component_count: 7,
320                },
321                extension: ".pkg",
322            },
323            Self::CircleControl => ProtocolObjectMetadata {
324                aad_label: b"circle-control",
325                path: ProtocolPathRule::Exact(&[
326                    ExactPathShape {
327                        component_count: 10,
328                        fixed_components: &[(0, "circle-control"), (2, "merge"), (3, "entries")],
329                    },
330                    ExactPathShape {
331                        component_count: 9,
332                        fixed_components: &[(0, "circle-control"), (2, "merge"), (3, "heads")],
333                    },
334                    ExactPathShape {
335                        component_count: 6,
336                        fixed_components: &[(0, "circle-control"), (2, "serial")],
337                    },
338                ]),
339                extension: ".json",
340            },
341            Self::CircleRoster => ProtocolObjectMetadata {
342                aad_label: b"circle-roster",
343                path: ProtocolPathRule::Exact(&[
344                    ExactPathShape {
345                        component_count: 10,
346                        fixed_components: &[(0, "circles"), (2, "roster"), (3, "entries")],
347                    },
348                    ExactPathShape {
349                        component_count: 9,
350                        fixed_components: &[(0, "circles"), (2, "roster"), (3, "heads")],
351                    },
352                ]),
353                extension: ".json",
354            },
355            Self::CircleRosterResolution => ProtocolObjectMetadata {
356                aad_label: b"circle-roster-resolution",
357                path: ProtocolPathRule::Exact(&[ExactPathShape {
358                    component_count: 7,
359                    fixed_components: &[(0, "circles"), (2, "roster"), (3, "resolutions")],
360                }]),
361                extension: ".json",
362            },
363            Self::CircleMetadata => ProtocolObjectMetadata {
364                aad_label: b"circle-metadata",
365                path: ProtocolPathRule::Exact(&[
366                    ExactPathShape {
367                        component_count: 10,
368                        fixed_components: &[(0, "circles"), (2, "metadata"), (3, "entries")],
369                    },
370                    ExactPathShape {
371                        component_count: 9,
372                        fixed_components: &[(0, "circles"), (2, "metadata"), (3, "heads")],
373                    },
374                ]),
375                extension: ".json",
376            },
377            Self::CirclePackage => ProtocolObjectMetadata {
378                aad_label: b"circle-package",
379                path: ProtocolPathRule::CircleCandidate {
380                    kind: "packages",
381                    component_count: 8,
382                },
383                extension: ".pkg",
384            },
385            Self::CircleAccessLeaf => ProtocolObjectMetadata {
386                aad_label: b"circle-access-leaf",
387                path: ProtocolPathRule::CircleCandidate {
388                    kind: "access-leaves",
389                    component_count: 9,
390                },
391                extension: "",
392            },
393            Self::CircleAccessEnvelope => ProtocolObjectMetadata {
394                aad_label: b"circle-access-envelope",
395                path: ProtocolPathRule::CircleCandidate {
396                    kind: "access-envelopes",
397                    component_count: 8,
398                },
399                extension: ".json",
400            },
401        }
402    }
403
404    pub(crate) fn aad_label(self) -> &'static [u8] {
405        self.metadata().aad_label
406    }
407
408    pub(crate) fn extension(self) -> &'static str {
409        self.metadata().extension
410    }
411}
412
413/// A domain protected by the Store key.
414#[derive(Clone, Copy, Debug, PartialEq, Eq)]
415pub struct StoreProtocolObjectDomain(ProtectedObjectDomain);
416
417/// A domain protected by a Circle epoch key.
418#[derive(Clone, Copy, Debug, PartialEq, Eq)]
419pub struct CircleProtocolObjectDomain(ProtectedObjectDomain);
420
421/// Typed protocol-object domain names. Each name's value carries the only
422/// protection class its object kind permits.
423pub struct ProtocolObjectDomain;
424
425#[allow(non_upper_case_globals)]
426impl ProtocolObjectDomain {
427    pub const StoreProtocolRoot: StoreProtocolObjectDomain =
428        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreProtocolRoot);
429    pub const StoreCommit: StoreProtocolObjectDomain =
430        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreCommit);
431    pub const StoreHead: StoreProtocolObjectDomain =
432        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreHead);
433    pub const StoreAck: StoreProtocolObjectDomain =
434        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreAck);
435    pub const StoreDeviceRegistration: StoreProtocolObjectDomain =
436        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreDeviceRegistration);
437    pub const StoreDeviceSelfRetirement: StoreProtocolObjectDomain =
438        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreDeviceSelfRetirement);
439    pub const DeviceJoinAttempt: StoreProtocolObjectDomain =
440        StoreProtocolObjectDomain(ProtectedObjectDomain::DeviceJoinAttempt);
441    pub const DeviceJoinOutcome: StoreProtocolObjectDomain =
442        StoreProtocolObjectDomain(ProtectedObjectDomain::DeviceJoinOutcome);
443    pub const DeviceJoinAbandonment: StoreProtocolObjectDomain =
444        StoreProtocolObjectDomain(ProtectedObjectDomain::DeviceJoinAbandonment);
445    pub const DeviceJoinCleanupReceipt: StoreProtocolObjectDomain =
446        StoreProtocolObjectDomain(ProtectedObjectDomain::DeviceJoinCleanupReceipt);
447    pub const ProviderAccessGrant: StoreProtocolObjectDomain =
448        StoreProtocolObjectDomain(ProtectedObjectDomain::ProviderAccessGrant);
449    pub const ProviderAccessWithdrawal: StoreProtocolObjectDomain =
450        StoreProtocolObjectDomain(ProtectedObjectDomain::ProviderAccessWithdrawal);
451    pub const OwnerRecoveryNode: StoreProtocolObjectDomain =
452        StoreProtocolObjectDomain(ProtectedObjectDomain::OwnerRecoveryNode);
453    pub const StoreSnapshotMeta: StoreProtocolObjectDomain =
454        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreSnapshotMeta);
455    pub const StoreSnapshotImage: StoreProtocolObjectDomain =
456        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreSnapshotImage);
457    pub const StoreMembershipEntry: StoreProtocolObjectDomain =
458        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreMembershipEntry);
459    pub const StoreMembershipHead: StoreProtocolObjectDomain =
460        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreMembershipHead);
461    pub const StoreMembershipResolution: StoreProtocolObjectDomain =
462        StoreProtocolObjectDomain(ProtectedObjectDomain::StoreMembershipResolution);
463    pub const StorePackage: StoreProtocolObjectDomain =
464        StoreProtocolObjectDomain(ProtectedObjectDomain::StorePackage);
465    pub const CircleControl: StoreProtocolObjectDomain =
466        StoreProtocolObjectDomain(ProtectedObjectDomain::CircleControl);
467    pub const CircleAccessEnvelope: StoreProtocolObjectDomain =
468        StoreProtocolObjectDomain(ProtectedObjectDomain::CircleAccessEnvelope);
469    pub const CircleRoster: CircleProtocolObjectDomain =
470        CircleProtocolObjectDomain(ProtectedObjectDomain::CircleRoster);
471    pub const CircleRosterResolution: CircleProtocolObjectDomain =
472        CircleProtocolObjectDomain(ProtectedObjectDomain::CircleRosterResolution);
473    pub const CircleMetadata: CircleProtocolObjectDomain =
474        CircleProtocolObjectDomain(ProtectedObjectDomain::CircleMetadata);
475    pub const CirclePackage: CircleProtocolObjectDomain =
476        CircleProtocolObjectDomain(ProtectedObjectDomain::CirclePackage);
477}
478
479/// Authenticated storage context for one immutable semantic object.
480///
481/// Store protection cannot be paired with a Circle-encrypted domain:
482///
483/// ```compile_fail
484/// use coven_core::sync::storage::{ProtocolObjectContext, ProtocolObjectDomain};
485/// use coven_core::ObjectHash;
486///
487/// let root = ObjectHash::digest(b"store");
488/// let _ = ProtocolObjectContext::store(root, ProtocolObjectDomain::CircleMetadata);
489/// ```
490///
491/// Circle protection cannot be paired with a Store domain:
492///
493/// ```compile_fail
494/// use coven_core::sync::storage::{ProtocolObjectContext, ProtocolObjectDomain};
495/// use coven_core::{EncryptionService, ObjectHash};
496///
497/// let root = ObjectHash::digest(b"store");
498/// let encryption = EncryptionService::from_key([7; 32]);
499/// let _ = ProtocolObjectContext::circle(
500///     root,
501///     ProtocolObjectDomain::StoreCommit,
502///     encryption,
503/// );
504/// ```
505pub struct ProtocolObjectContext {
506    store_root_hash: ObjectHash,
507    domain: ProtectedObjectDomain,
508    protection: ProtocolObjectProtection,
509}
510
511#[derive(Clone)]
512pub(crate) enum ProtocolObjectProtection {
513    Store,
514    Circle(crate::encryption::EncryptionService),
515    RecipientSealed,
516}
517
518impl ProtocolObjectContext {
519    pub fn store(store_root_hash: ObjectHash, domain: StoreProtocolObjectDomain) -> Self {
520        Self {
521            store_root_hash,
522            domain: domain.0,
523            protection: ProtocolObjectProtection::Store,
524        }
525    }
526
527    pub fn circle(
528        store_root_hash: ObjectHash,
529        domain: CircleProtocolObjectDomain,
530        encryption: crate::encryption::EncryptionService,
531    ) -> Self {
532        Self {
533            store_root_hash,
534            domain: domain.0,
535            protection: ProtocolObjectProtection::Circle(encryption),
536        }
537    }
538
539    pub fn recipient_sealed(store_root_hash: ObjectHash) -> Self {
540        Self {
541            store_root_hash,
542            domain: ProtectedObjectDomain::CircleAccessLeaf,
543            protection: ProtocolObjectProtection::RecipientSealed,
544        }
545    }
546
547    pub fn store_root_hash(&self) -> ObjectHash {
548        self.store_root_hash
549    }
550
551    pub(crate) fn domain(&self) -> ProtectedObjectDomain {
552        self.domain
553    }
554
555    pub(crate) fn protection(&self) -> &ProtocolObjectProtection {
556        &self.protection
557    }
558
559    pub fn validate_path(&self, semantic_prefix: &str) -> Result<(), StorageError> {
560        let metadata = self.domain.metadata();
561        if semantic_prefix.contains("/copies/") || !metadata.path.accepts(semantic_prefix) {
562            return Err(StorageError::Parse(format!(
563                "object domain {:?} does not accept semantic path {semantic_prefix:?}",
564                self.domain
565            )));
566        }
567        Ok(())
568    }
569
570    pub fn validate_extension(&self, extension: &str) -> Result<(), StorageError> {
571        if extension != self.domain.extension() {
572            return Err(StorageError::Parse(format!(
573                "object domain {:?} does not accept extension {extension:?}",
574                self.domain
575            )));
576        }
577        Ok(())
578    }
579
580    pub fn validate_reference(
581        &self,
582        object: &ExactObjectRef,
583        semantic_prefix: &str,
584    ) -> Result<(), StorageError> {
585        self.validate_slot(object.slot(), semantic_prefix)
586    }
587
588    pub fn validate_slot(
589        &self,
590        slot: &ObjectSlot,
591        semantic_prefix: &str,
592    ) -> Result<(), StorageError> {
593        self.validate_path(semantic_prefix)?;
594        let expected = format!("{semantic_prefix}{}", self.domain.extension());
595        if slot.logical_key() != expected {
596            return Err(StorageError::Parse(format!(
597                "protocol object {:?} does not match semantic path {semantic_prefix:?}",
598                slot.logical_key()
599            )));
600        }
601        Ok(())
602    }
603}
604
605#[derive(Clone, Debug, PartialEq, Eq)]
606pub struct VersionToken(CloudHeadVersion);
607
608impl VersionToken {
609    pub(crate) fn from_cloud(version: CloudHeadVersion) -> Self {
610        Self(version)
611    }
612
613    pub(crate) fn cloud(&self) -> &CloudHeadVersion {
614        &self.0
615    }
616}
617
618impl serde::Serialize for VersionToken {
619    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
620    where
621        S: serde::Serializer,
622    {
623        serializer.serialize_str(self.0.as_provider())
624    }
625}
626
627impl<'de> serde::Deserialize<'de> for VersionToken {
628    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
629    where
630        D: serde::Deserializer<'de>,
631    {
632        let value = String::deserialize(deserializer)?;
633        CloudHeadVersion::from_provider(value)
634            .map(Self)
635            .map_err(serde::de::Error::custom)
636    }
637}
638
639#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct VersionedObject {
642    pub bytes: Vec<u8>,
643    pub version: VersionToken,
644}
645
646/// Protection selected by the audience authority that prepares a blob spool.
647#[derive(Clone)]
648pub enum BlobSpoolProtection {
649    Opaque(crate::encryption::EncryptionService),
650    Browsable,
651}
652
653#[derive(Clone, Copy)]
654pub struct BlobWriteAuthority<'a> {
655    pub reference: &'a crate::sync::store_commit::StoreDeviceRegistrationRef,
656    pub registration: &'a crate::sync::store_commit::StoreDeviceRegistration,
657}
658
659impl<'a> BlobWriteAuthority<'a> {
660    pub fn new(
661        reference: &'a crate::sync::store_commit::StoreDeviceRegistrationRef,
662        registration: &'a crate::sync::store_commit::StoreDeviceRegistration,
663    ) -> Result<Self, StorageError> {
664        reference
665            .verify_registration(registration)
666            .map_err(|error| StorageError::InvalidContent(error.to_string()))?;
667        Ok(Self {
668            reference,
669            registration,
670        })
671    }
672}
673
674/// Provider namespace/corpus facts signed once by the Store root.
675#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
676#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
677pub enum StoreProviderBinding {
678    S3 {
679        endpoint: S3EndpointBinding,
680        region: String,
681        bucket: String,
682        key_prefix: Option<String>,
683    },
684    GoogleDrive {
685        corpus: GoogleDriveCorpus,
686    },
687    Dropbox {
688        namespace_id: String,
689    },
690    OneDrive {
691        drive_id: String,
692        folder_id: String,
693    },
694    CloudKit {
695        container_id: String,
696        environment: CloudKitEnvironment,
697        owner_name: String,
698        zone_name: String,
699    },
700}
701
702#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
703#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
704pub enum S3EndpointBinding {
705    Aws { partition: String },
706    Custom { origin: String },
707}
708
709#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
710#[serde(tag = "corpus", rename_all = "snake_case", deny_unknown_fields)]
711pub enum GoogleDriveCorpus {
712    MyDrive { folder_id: String },
713    SharedDrive { drive_id: String, folder_id: String },
714}
715
716#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
717#[serde(rename_all = "snake_case")]
718pub enum CloudKitEnvironment {
719    Development,
720    Production,
721}
722
723#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
724#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
725pub enum ProviderPrincipalId {
726    Aws {
727        account_id: String,
728        principal: AwsPrincipal,
729    },
730    CustomS3Credential {
731        access_key_id_hash: ObjectHash,
732    },
733    GoogleDrive {
734        permission_id: String,
735    },
736    Dropbox {
737        account_id: String,
738    },
739    OneDrive {
740        user_id: String,
741    },
742    CloudKitPrivateZoneOwner {
743        record_name: String,
744    },
745    CloudKitSharedZoneParticipant {
746        record_name: String,
747    },
748}
749
750#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
751#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
752pub enum AwsPrincipal {
753    Root,
754    User { arn: String, user_id: String },
755    Role { role_id: String },
756}
757
758#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
759#[serde(deny_unknown_fields)]
760pub struct ProviderDeviceBinding {
761    pub principal: ProviderPrincipalId,
762}
763
764#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
765#[serde(deny_unknown_fields)]
766pub struct ResolvedProviderBinding {
767    pub store: StoreProviderBinding,
768    pub device: ProviderDeviceBinding,
769}
770
771impl StoreProviderBinding {
772    pub fn validate(&self) -> Result<(), StorageError> {
773        fn present(label: &str, value: &str) -> Result<(), StorageError> {
774            if value.is_empty() {
775                Err(StorageError::Configuration(format!("{label} is empty")))
776            } else {
777                Ok(())
778            }
779        }
780
781        match self {
782            Self::S3 {
783                endpoint,
784                region,
785                bucket,
786                key_prefix,
787            } => {
788                present("S3 region", region)?;
789                present("S3 bucket", bucket)?;
790                if key_prefix.as_deref().is_some_and(str::is_empty) {
791                    return Err(StorageError::Configuration(
792                        "S3 key prefix is empty instead of absent".to_string(),
793                    ));
794                }
795                match endpoint {
796                    S3EndpointBinding::Aws { partition } => present("AWS partition", partition),
797                    S3EndpointBinding::Custom { origin } => {
798                        let canonical = super::provider::canonical_custom_s3_origin(origin)?;
799                        if canonical != *origin {
800                            return Err(StorageError::Configuration(
801                                "custom S3 origin is not canonical".to_string(),
802                            ));
803                        }
804                        Ok(())
805                    }
806                }
807            }
808            Self::GoogleDrive { corpus } => match corpus {
809                GoogleDriveCorpus::MyDrive { folder_id } => {
810                    present("Google Drive folder id", folder_id)
811                }
812                GoogleDriveCorpus::SharedDrive {
813                    drive_id,
814                    folder_id,
815                } => {
816                    present("Google Drive id", drive_id)?;
817                    present("Google Drive folder id", folder_id)
818                }
819            },
820            Self::Dropbox { namespace_id } => present("Dropbox namespace id", namespace_id),
821            Self::OneDrive {
822                drive_id,
823                folder_id,
824            } => {
825                present("OneDrive drive id", drive_id)?;
826                present("OneDrive folder id", folder_id)
827            }
828            Self::CloudKit {
829                container_id,
830                owner_name,
831                zone_name,
832                ..
833            } => {
834                present("CloudKit container id", container_id)?;
835                present("CloudKit owner name", owner_name)?;
836                present("CloudKit zone name", zone_name)
837            }
838        }
839    }
840}
841
842impl ProviderDeviceBinding {
843    pub fn validate_for(&self, store: &StoreProviderBinding) -> Result<(), StorageError> {
844        fn present(label: &str, value: &str) -> Result<(), StorageError> {
845            if value.is_empty() {
846                Err(StorageError::Configuration(format!("{label} is empty")))
847            } else {
848                Ok(())
849            }
850        }
851
852        let compatible = matches!(
853            (store, &self.principal),
854            (
855                StoreProviderBinding::S3 {
856                    endpoint: S3EndpointBinding::Aws { .. },
857                    ..
858                },
859                ProviderPrincipalId::Aws { .. }
860            ) | (
861                StoreProviderBinding::S3 {
862                    endpoint: S3EndpointBinding::Custom { .. },
863                    ..
864                },
865                ProviderPrincipalId::CustomS3Credential { .. }
866            ) | (
867                StoreProviderBinding::GoogleDrive { .. },
868                ProviderPrincipalId::GoogleDrive { .. }
869            ) | (
870                StoreProviderBinding::Dropbox { .. },
871                ProviderPrincipalId::Dropbox { .. }
872            ) | (
873                StoreProviderBinding::OneDrive { .. },
874                ProviderPrincipalId::OneDrive { .. }
875            ) | (
876                StoreProviderBinding::CloudKit { .. },
877                ProviderPrincipalId::CloudKitPrivateZoneOwner { .. }
878                    | ProviderPrincipalId::CloudKitSharedZoneParticipant { .. }
879            )
880        );
881        if !compatible {
882            return Err(StorageError::Configuration(
883                "provider principal is incompatible with the Store provider binding".to_string(),
884            ));
885        }
886        match &self.principal {
887            ProviderPrincipalId::Aws {
888                account_id,
889                principal,
890            } => {
891                if account_id.len() != 12 || !account_id.bytes().all(|byte| byte.is_ascii_digit()) {
892                    return Err(StorageError::Configuration(
893                        "AWS account id must contain exactly 12 decimal digits".to_string(),
894                    ));
895                }
896                match principal {
897                    AwsPrincipal::Root => Ok(()),
898                    AwsPrincipal::User { arn, user_id } => {
899                        present("AWS user id", user_id)?;
900                        let fields: Vec<_> = arn.splitn(6, ':').collect();
901                        let StoreProviderBinding::S3 {
902                            endpoint: S3EndpointBinding::Aws { partition },
903                            ..
904                        } = store
905                        else {
906                            return Err(StorageError::Configuration(
907                                "AWS user principal is bound to non-AWS S3".to_string(),
908                            ));
909                        };
910                        if fields.len() != 6
911                            || fields[0] != "arn"
912                            || fields[1] != partition
913                            || fields[2] != "iam"
914                            || !fields[3].is_empty()
915                            || fields[4] != account_id
916                            || !fields[5].starts_with("user/")
917                            || fields[5].len() == "user/".len()
918                        {
919                            return Err(StorageError::Configuration(
920                                "AWS IAM user ARN is malformed or differs from its Store binding"
921                                    .to_string(),
922                            ));
923                        }
924                        Ok(())
925                    }
926                    AwsPrincipal::Role { role_id } => {
927                        present("AWS role id", role_id)?;
928                        if role_id.contains(':') {
929                            return Err(StorageError::Configuration(
930                                "AWS role id must be the stable prefix before the session separator"
931                                    .to_string(),
932                            ));
933                        }
934                        Ok(())
935                    }
936                }
937            }
938            ProviderPrincipalId::CustomS3Credential { .. } => Ok(()),
939            ProviderPrincipalId::GoogleDrive { permission_id } => {
940                present("Google Drive permission id", permission_id)
941            }
942            ProviderPrincipalId::Dropbox { account_id } => {
943                present("Dropbox account id", account_id)
944            }
945            ProviderPrincipalId::OneDrive { user_id } => present("OneDrive user id", user_id),
946            ProviderPrincipalId::CloudKitPrivateZoneOwner { record_name } => {
947                present("CloudKit private-zone owner record name", record_name)
948            }
949            ProviderPrincipalId::CloudKitSharedZoneParticipant { record_name } => {
950                present("CloudKit shared-zone participant record name", record_name)
951            }
952        }
953    }
954}
955
956impl ResolvedProviderBinding {
957    pub fn validate(&self) -> Result<(), StorageError> {
958        self.store.validate()?;
959        self.device.validate_for(&self.store)
960    }
961}
962
963/// Exact stored representation of one immutable object.
964#[derive(
965    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
966)]
967#[serde(deny_unknown_fields)]
968pub struct ExactObjectRef {
969    slot: ObjectSlot,
970    stored_size: u64,
971    stored_hash: ObjectHash,
972}
973
974impl ExactObjectRef {
975    pub fn new(slot: ObjectSlot, stored_size: u64, stored_hash: ObjectHash) -> Self {
976        Self {
977            slot,
978            stored_size,
979            stored_hash,
980        }
981    }
982
983    pub fn slot(&self) -> &ObjectSlot {
984        &self.slot
985    }
986
987    pub fn stored_size(&self) -> u64 {
988        self.stored_size
989    }
990
991    pub fn stored_hash(&self) -> ObjectHash {
992        self.stored_hash
993    }
994
995    pub fn verify(&self, bytes: &[u8]) -> Result<(), StorageError> {
996        if bytes.len() as u64 != self.stored_size || ObjectHash::digest(bytes) != self.stored_hash {
997            return Err(StorageError::InvalidContent(format!(
998                "exact object {} does not match stored size/hash",
999                self.slot.logical_key()
1000            )));
1001        }
1002        Ok(())
1003    }
1004}
1005
1006/// Immutable stored bytes and the exact reference derived from them.
1007#[derive(Clone, Debug, serde::Serialize)]
1008#[serde(deny_unknown_fields)]
1009pub struct PreparedExactObject {
1010    reference: ExactObjectRef,
1011    stored_bytes: Vec<u8>,
1012}
1013
1014impl<'de> serde::Deserialize<'de> for PreparedExactObject {
1015    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1016    where
1017        D: serde::Deserializer<'de>,
1018    {
1019        #[derive(serde::Deserialize)]
1020        #[serde(deny_unknown_fields)]
1021        struct Fields {
1022            reference: ExactObjectRef,
1023            stored_bytes: Vec<u8>,
1024        }
1025
1026        let fields = Fields::deserialize(deserializer)?;
1027        Self::new(fields.reference, fields.stored_bytes).map_err(serde::de::Error::custom)
1028    }
1029}
1030
1031impl PreparedExactObject {
1032    pub fn new(reference: ExactObjectRef, stored_bytes: Vec<u8>) -> Result<Self, StorageError> {
1033        reference.verify(&stored_bytes)?;
1034        Ok(Self {
1035            reference,
1036            stored_bytes,
1037        })
1038    }
1039
1040    pub fn reference(&self) -> &ExactObjectRef {
1041        &self.reference
1042    }
1043
1044    pub fn stored_bytes(&self) -> &[u8] {
1045        &self.stored_bytes
1046    }
1047}
1048
1049#[derive(Debug, thiserror::Error)]
1050pub enum CoordinationError {
1051    #[error("serial coordination is unavailable: {0}")]
1052    Unavailable(String),
1053    #[error("coordination head not found: {0}")]
1054    NotFound(String),
1055    #[error("coordination storage failed: {0}")]
1056    Storage(String),
1057    #[error("coordination object could not be opened: {0}")]
1058    Open(String),
1059}
1060
1061#[derive(Debug, thiserror::Error)]
1062pub enum CreateHeadError {
1063    #[error("coordination head already exists")]
1064    AlreadyExists,
1065    #[error(transparent)]
1066    Coordination(#[from] CoordinationError),
1067}
1068
1069#[derive(Debug, thiserror::Error)]
1070pub enum ReplaceHeadError {
1071    #[error("coordination head version no longer matches")]
1072    VersionMismatch,
1073    #[error(transparent)]
1074    Coordination(#[from] CoordinationError),
1075}
1076
1077/// Mandatory compare-and-swap operations exposed only by eligible adapters.
1078#[async_trait]
1079pub trait CoordinationStorage: Send + Sync {
1080    async fn provider_binding(&self) -> Result<ResolvedProviderBinding, CoordinationError>;
1081
1082    async fn read_head(&self, key: &str) -> Result<VersionedObject, CoordinationError>;
1083
1084    async fn create_head(
1085        &self,
1086        key: &str,
1087        bytes: &[u8],
1088    ) -> Result<VersionedObject, CreateHeadError>;
1089
1090    async fn replace_head(
1091        &self,
1092        key: &str,
1093        expected: &VersionToken,
1094        bytes: &[u8],
1095    ) -> Result<VersionedObject, ReplaceHeadError>;
1096
1097    async fn delete_probe_head(&self, key: &str) -> Result<(), CoordinationError>;
1098}
1099
1100/// Error type for storage operations.
1101#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
1102pub enum StorageError {
1103    #[error("storage operation failed: {0}")]
1104    Storage(String),
1105    #[error("{operation}; storage cleanup failed: {cleanup}")]
1106    CleanupFailed {
1107        #[source]
1108        operation: Box<StorageError>,
1109        cleanup: Box<StorageError>,
1110    },
1111    #[error("{operation}; exact response-loss readback failed: {readback}")]
1112    UnresolvedOutcome {
1113        #[source]
1114        operation: Box<StorageError>,
1115        readback: Box<StorageError>,
1116    },
1117    #[error("storage configuration is invalid: {0}")]
1118    Configuration(String),
1119    #[error("storage object parse failed: {0}")]
1120    Parse(String),
1121    #[error("object not found: {0}")]
1122    NotFound(String),
1123    #[error("storage object already exists: {0}")]
1124    AlreadyExists(String),
1125    #[error("reserved storage slot contains different bytes: {0}")]
1126    SlotCollision(String),
1127    #[error("decryption failed: {0}")]
1128    Decryption(String),
1129    #[error("remote blob content is invalid: {0}")]
1130    InvalidContent(String),
1131    #[error("local blob filesystem failed: {0}")]
1132    LocalFilesystem(String),
1133    /// This device has not adopted a store-key rotation the cloud already
1134    /// committed; see [`crate::sync::cloud_storage::RotationPending`].
1135    #[error("{0}")]
1136    RotationPending(#[from] crate::sync::cloud_storage::RotationPending),
1137}
1138
1139impl From<crate::storage::cloud::CloudHomeError> for StorageError {
1140    fn from(e: crate::storage::cloud::CloudHomeError) -> Self {
1141        match e {
1142            crate::storage::cloud::CloudHomeError::NotFound(key) => StorageError::NotFound(key),
1143            crate::storage::cloud::CloudHomeError::AlreadyExists(key) => {
1144                StorageError::AlreadyExists(key)
1145            }
1146            crate::storage::cloud::CloudHomeError::Configuration(msg) => {
1147                StorageError::Configuration(msg)
1148            }
1149            crate::storage::cloud::CloudHomeError::Transport(msg) => StorageError::Storage(msg),
1150            crate::storage::cloud::CloudHomeError::CleanupFailed { operation, cleanup } => {
1151                StorageError::CleanupFailed {
1152                    operation: Box::new(StorageError::from(*operation)),
1153                    cleanup: Box::new(StorageError::from(*cleanup)),
1154                }
1155            }
1156            crate::storage::cloud::CloudHomeError::UnresolvedOutcome {
1157                operation,
1158                readback,
1159            } => StorageError::UnresolvedOutcome {
1160                operation: Box::new(StorageError::from(*operation)),
1161                readback: Box::new(StorageError::from(*readback)),
1162            },
1163            crate::storage::cloud::CloudHomeError::Io(io_err) => {
1164                StorageError::Storage(format!("I/O error: {io_err}"))
1165            }
1166        }
1167    }
1168}
1169
1170impl StorageError {
1171    pub fn is_transport(&self) -> bool {
1172        match self {
1173            Self::Storage(_) => true,
1174            Self::CleanupFailed { operation, .. } | Self::UnresolvedOutcome { operation, .. } => {
1175                operation.is_transport()
1176            }
1177            _ => false,
1178        }
1179    }
1180
1181    pub(crate) fn definitely_uncommitted(&self) -> bool {
1182        !self.is_transport()
1183    }
1184
1185    pub fn cleanup_causes(&self) -> Option<(&StorageError, &StorageError)> {
1186        match self {
1187            Self::CleanupFailed { operation, cleanup } => Some((operation, cleanup)),
1188            _ => None,
1189        }
1190    }
1191}
1192
1193impl From<crate::store_dir::PathTokenError> for StorageError {
1194    /// A blob id/namespace/cloud_path that can't form a safe object key is bad
1195    /// data, surfaced so the caller refuses the blob rather than reaching storage
1196    /// with a key that could escape its prefix.
1197    fn from(e: crate::store_dir::PathTokenError) -> Self {
1198        StorageError::Parse(format!("unsafe blob path: {e}"))
1199    }
1200}
1201
1202#[async_trait]
1203pub trait SyncStorage: Send + Sync {
1204    /// Return the cloud home's fixed Store blob opening protection. Circle blobs
1205    /// use their exact activated Circle key instead.
1206    fn store_blob_protection(&self) -> Result<BlobSpoolProtection, StorageError>;
1207
1208    /// Resolve the provider corpus and authenticated principal used by this
1209    /// adapter. Registrations bind the principal before allocating descendants.
1210    async fn provider_binding(&self) -> Result<ResolvedProviderBinding, StorageError>;
1211
1212    /// Reserve the exact provider slot for a protocol object.
1213    async fn allocate_protocol_slot(
1214        &self,
1215        context: &ProtocolObjectContext,
1216        semantic_prefix: &str,
1217        extension: &str,
1218    ) -> Result<ObjectSlot, StorageError>;
1219
1220    /// Seal canonical protocol bytes once and bind their exact stored size/hash.
1221    fn prepare_protocol_object(
1222        &self,
1223        context: &ProtocolObjectContext,
1224        slot: ObjectSlot,
1225        semantic_prefix: &str,
1226        data: Vec<u8>,
1227    ) -> Result<PreparedExactObject, StorageError>;
1228
1229    /// Create the prepared bytes at their reserved slot, settling lost responses
1230    /// by exact readback and refusing different bytes at an occupied slot.
1231    async fn create_protocol_object(
1232        &self,
1233        prepared: &PreparedExactObject,
1234    ) -> Result<(), StorageError>;
1235
1236    /// Read and open one exact Store protocol object using the signed
1237    /// semantic prefix as encryption AAD.
1238    async fn read_protocol_object(
1239        &self,
1240        context: &ProtocolObjectContext,
1241        object: &ExactObjectRef,
1242        semantic_prefix: &str,
1243    ) -> Result<Vec<u8>, StorageError>;
1244
1245    /// Read one predecessor-reserved successor slot and return both its opened
1246    /// bytes and the completed exact reference derived from the stored bytes.
1247    async fn read_protocol_slot(
1248        &self,
1249        context: &ProtocolObjectContext,
1250        slot: &ObjectSlot,
1251        semantic_prefix: &str,
1252    ) -> Result<(Vec<u8>, ExactObjectRef), StorageError>;
1253
1254    /// Read one predecessor-reserved successor slot while retaining its exact
1255    /// stored representation for a durable retry journal.
1256    async fn read_prepared_protocol_slot(
1257        &self,
1258        context: &ProtocolObjectContext,
1259        slot: &ObjectSlot,
1260        semantic_prefix: &str,
1261    ) -> Result<(Vec<u8>, PreparedExactObject), StorageError>;
1262
1263    /// Delete one exact Store protocol object and verify absence.
1264    async fn delete_protocol_object(&self, object: &ExactObjectRef) -> Result<(), StorageError>;
1265
1266    /// Reserve the exact provider slot for a stored blob body.
1267    async fn allocate_blob_slot(
1268        &self,
1269        locator: &crate::blob::locator::BlobLocator,
1270        authority: &BlobWriteAuthority<'_>,
1271    ) -> Result<ObjectSlot, StorageError>;
1272
1273    /// Verify one plaintext source against its locator and write the exact stored
1274    /// representation to an atomically committed, directory-synced spool file.
1275    async fn seal_blob_to_spool(
1276        &self,
1277        locator: &crate::blob::locator::BlobLocator,
1278        authority: &BlobWriteAuthority<'_>,
1279        protection: BlobSpoolProtection,
1280        plaintext_file: &Path,
1281        spool_file: &Path,
1282    ) -> Result<(), StorageError>;
1283
1284    /// Derive an exact reference from an immutable stored blob file.
1285    async fn prepare_blob_object(
1286        &self,
1287        locator: &crate::blob::locator::BlobLocator,
1288        authority: &BlobWriteAuthority<'_>,
1289        slot: ObjectSlot,
1290        stored_file: &Path,
1291    ) -> Result<crate::blob::locator::StoredBlobRef, StorageError>;
1292
1293    /// Create the exact stored blob body from its immutable local file.
1294    async fn create_blob_object_from_file(
1295        &self,
1296        blob: &crate::blob::locator::StoredBlobRef,
1297        authority: &BlobWriteAuthority<'_>,
1298        stored_file: &Path,
1299        progress: &crate::storage::cloud::UploadProgress<'_>,
1300    ) -> Result<(), StorageError>;
1301
1302    /// Read one exact stored blob body and verify its signed size/hash reference.
1303    async fn verify_blob_object(
1304        &self,
1305        blob: &crate::blob::locator::StoredBlobRef,
1306    ) -> Result<(), StorageError>;
1307
1308    /// Read and verify one exact stored blob body into an unpublished sibling.
1309    /// The caller commits it with overwrite semantics for coven-owned paths or
1310    /// no-replace semantics for user-owned destinations.
1311    async fn stage_exact_blob_download(
1312        &self,
1313        blob: &crate::blob::locator::StoredBlobRef,
1314        dest: &Path,
1315    ) -> Result<crate::local_blob::AtomicStagedFile, StorageError>;
1316
1317    /// Download and exact-verify the stored object, open it under the
1318    /// audience-owned protection, and return an unpublished plaintext file only
1319    /// after its locator size and hash have also been verified.
1320    async fn stage_verified_blob_plaintext(
1321        &self,
1322        blob: &crate::blob::locator::StoredBlobRef,
1323        protection: BlobSpoolProtection,
1324        dest: &Path,
1325    ) -> Result<crate::local_blob::AtomicStagedFile, StorageError>;
1326
1327    /// Delete one exact stored blob body.
1328    async fn delete_blob_object(
1329        &self,
1330        blob: &crate::blob::locator::StoredBlobRef,
1331    ) -> Result<(), StorageError>;
1332
1333    /// Upload a wrapped store key that `owner_pubkey` sealed for `recipient_pubkey`.
1334    /// Writes to `keys/{owner_pubkey_hex}/{recipient_pubkey_hex}{suffix}`. An owner
1335    /// wraps only into its own prefix, so a recipient can hold a wrap from each
1336    /// owner and no two owners contend for one slot. The bytes are already a sealed
1337    /// box, so the home cipher stores them verbatim regardless of suffix.
1338    async fn put_wrapped_key(
1339        &self,
1340        owner_pubkey: &str,
1341        recipient_pubkey: &str,
1342        data: Vec<u8>,
1343    ) -> Result<(), StorageError>;
1344
1345    /// Download the wrapped store key `owner_pubkey` sealed for `recipient_pubkey`.
1346    /// Reads from `keys/{owner_pubkey_hex}/{recipient_pubkey_hex}{suffix}`.
1347    /// `create_invitation` reads the inviting owner's existing slot for the invitee
1348    /// before overwriting it, so a failed invite can restore the exact prior object
1349    /// rather than stripping a re-invited member's wrapped key. Returns `NotFound`
1350    /// when that owner has no wrapped key for the recipient yet.
1351    async fn get_wrapped_key(
1352        &self,
1353        owner_pubkey: &str,
1354        recipient_pubkey: &str,
1355    ) -> Result<Vec<u8>, StorageError>;
1356
1357    /// Delete the wrapped store key `owner_pubkey` sealed for `recipient_pubkey`.
1358    /// Removes `keys/{owner_pubkey_hex}/{recipient_pubkey_hex}{suffix}`. An owner
1359    /// can delete only wraps in its own prefix; a revoked member's wraps under
1360    /// other owners' prefixes are pre-rotation (they wrap a key the member already
1361    /// held) and are reclaimed when those owners next rotate.
1362    async fn delete_wrapped_key(
1363        &self,
1364        owner_pubkey: &str,
1365        recipient_pubkey: &str,
1366    ) -> Result<(), StorageError>;
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371    use super::*;
1372
1373    struct DomainPathCase {
1374        domain: ProtectedObjectDomain,
1375        valid: &'static [&'static str],
1376        cross_domain: &'static str,
1377    }
1378
1379    fn validates(domain: ProtectedObjectDomain, path: &str) -> bool {
1380        ProtocolObjectContext {
1381            store_root_hash: ObjectHash::digest(b"protocol path grammar"),
1382            domain,
1383            protection: ProtocolObjectProtection::Store,
1384        }
1385        .validate_path(path)
1386        .is_ok()
1387    }
1388
1389    #[test]
1390    fn every_protocol_domain_requires_its_exact_path_grammar() {
1391        let cases = [
1392            DomainPathCase {
1393                domain: ProtectedObjectDomain::StoreProtocolRoot,
1394                valid: &["store-v1/store-protocol-root"],
1395                cross_domain: "store-v1/heads/device/1",
1396            },
1397            DomainPathCase {
1398                domain: ProtectedObjectDomain::StoreCommit,
1399                valid: &["store-v1/candidates/family/commits/device/1/hash"],
1400                cross_domain: "store-v1/candidates/family/packages/device/1/hash",
1401            },
1402            DomainPathCase {
1403                domain: ProtectedObjectDomain::StoreHead,
1404                valid: &["store-v1/heads/device/1"],
1405                cross_domain: "store-v1/acks/device/1",
1406            },
1407            DomainPathCase {
1408                domain: ProtectedObjectDomain::StoreAck,
1409                valid: &["store-v1/acks/device/1"],
1410                cross_domain: "store-v1/heads/device/1",
1411            },
1412            DomainPathCase {
1413                domain: ProtectedObjectDomain::StoreDeviceRegistration,
1414                valid: &[
1415                    "store-v1/devices/device",
1416                    "store-v1/devices/founder/creation/registration",
1417                ],
1418                cross_domain: "store-v1/device-join-attempts/attempt",
1419            },
1420            DomainPathCase {
1421                domain: ProtectedObjectDomain::StoreDeviceSelfRetirement,
1422                valid: &["store-v1/candidates/family/device-self-retirements/device/hash"],
1423                cross_domain: "store-v1/candidates/family/packages/device/1/hash",
1424            },
1425            DomainPathCase {
1426                domain: ProtectedObjectDomain::DeviceJoinAttempt,
1427                valid: &["store-v1/device-join-attempts/attempt"],
1428                cross_domain: "store-v1/device-join-outcomes/attempt",
1429            },
1430            DomainPathCase {
1431                domain: ProtectedObjectDomain::DeviceJoinOutcome,
1432                valid: &["store-v1/device-join-outcomes/attempt"],
1433                cross_domain: "store-v1/device-join-attempts/attempt",
1434            },
1435            DomainPathCase {
1436                domain: ProtectedObjectDomain::DeviceJoinAbandonment,
1437                valid: &["store-v1/device-join-attempts/attempt"],
1438                cross_domain: "store-v1/device-join-outcomes/attempt",
1439            },
1440            DomainPathCase {
1441                domain: ProtectedObjectDomain::DeviceJoinCleanupReceipt,
1442                valid: &["store-v1/device-join-cleanup-receipts/attempt"],
1443                cross_domain: "store-v1/device-join-attempts/attempt",
1444            },
1445            DomainPathCase {
1446                domain: ProtectedObjectDomain::ProviderAccessGrant,
1447                valid: &["store-v1/provider-access/grants/grant"],
1448                cross_domain: "store-v1/provider-access/withdrawals/grant",
1449            },
1450            DomainPathCase {
1451                domain: ProtectedObjectDomain::ProviderAccessWithdrawal,
1452                valid: &["store-v1/provider-access/withdrawals/grant"],
1453                cross_domain: "store-v1/provider-access/grants/grant",
1454            },
1455            DomainPathCase {
1456                domain: ProtectedObjectDomain::OwnerRecoveryNode,
1457                valid: &["store-v1/recovery/owner/grant/1"],
1458                cross_domain: "store-v1/snapshots/owner/hash",
1459            },
1460            DomainPathCase {
1461                domain: ProtectedObjectDomain::StoreSnapshotMeta,
1462                valid: &["store-v1/snapshots/owner/hash"],
1463                cross_domain: "store-v1/snapshot-images/owner/hash",
1464            },
1465            DomainPathCase {
1466                domain: ProtectedObjectDomain::StoreSnapshotImage,
1467                valid: &["store-v1/snapshot-images/owner/hash"],
1468                cross_domain: "store-v1/snapshots/owner/hash",
1469            },
1470            DomainPathCase {
1471                domain: ProtectedObjectDomain::StoreMembershipEntry,
1472                valid: &["store-v1/membership/entries/owner/grant/stream/1/hash"],
1473                cross_domain: "store-v1/membership/heads/owner/grant/stream/1/hash",
1474            },
1475            DomainPathCase {
1476                domain: ProtectedObjectDomain::StoreMembershipHead,
1477                valid: &[
1478                    "store-v1/membership/heads/owner/grant/stream/1",
1479                    "store-v1/membership/heads/founder/creation/1",
1480                ],
1481                cross_domain: "store-v1/membership/entries/owner/grant/stream/1/hash",
1482            },
1483            DomainPathCase {
1484                domain: ProtectedObjectDomain::StoreMembershipResolution,
1485                valid: &["store-v1/membership/resolutions/conflict/resolver/hash"],
1486                cross_domain: "store-v1/membership/entries/owner/grant/stream/1/hash",
1487            },
1488            DomainPathCase {
1489                domain: ProtectedObjectDomain::StorePackage,
1490                valid: &["store-v1/candidates/family/packages/device/1/hash"],
1491                cross_domain: "store-v1/candidates/family/commits/device/1/hash",
1492            },
1493            DomainPathCase {
1494                domain: ProtectedObjectDomain::CircleControl,
1495                valid: &[
1496                    "circle-control/circle/merge/entries/owner/device/grant/stream/1/hash",
1497                    "circle-control/circle/merge/heads/owner/device/grant/stream/1",
1498                    "circle-control/circle/serial/owner/1/hash",
1499                ],
1500                cross_domain: "circles/circle/roster/entries/owner/device/grant/stream/1/hash",
1501            },
1502            DomainPathCase {
1503                domain: ProtectedObjectDomain::CircleRoster,
1504                valid: &[
1505                    "circles/circle/roster/entries/owner/device/grant/stream/1/hash",
1506                    "circles/circle/roster/heads/owner/device/grant/stream/1",
1507                ],
1508                cross_domain: "circles/circle/roster/resolutions/conflict/resolver/hash",
1509            },
1510            DomainPathCase {
1511                domain: ProtectedObjectDomain::CircleRosterResolution,
1512                valid: &["circles/circle/roster/resolutions/conflict/resolver/hash"],
1513                cross_domain: "circles/circle/roster/entries/owner/device/grant/stream/1/hash",
1514            },
1515            DomainPathCase {
1516                domain: ProtectedObjectDomain::CircleMetadata,
1517                valid: &[
1518                    "circles/circle/metadata/entries/owner/device/grant/stream/1/hash",
1519                    "circles/circle/metadata/heads/owner/device/grant/stream/1",
1520                ],
1521                cross_domain: "circles/circle/roster/entries/owner/device/grant/stream/1/hash",
1522            },
1523            DomainPathCase {
1524                domain: ProtectedObjectDomain::CirclePackage,
1525                valid: &["circles/circle/candidates/family/packages/device/1/hash"],
1526                cross_domain:
1527                    "circles/circle/candidates/family/access-envelopes/owner/recipient/hash",
1528            },
1529            DomainPathCase {
1530                domain: ProtectedObjectDomain::CircleAccessLeaf,
1531                valid: &[
1532                    "circles/circle/candidates/family/access-leaves/owner/epoch/recipient/leaf",
1533                ],
1534                cross_domain:
1535                    "circles/circle/candidates/family/access-envelopes/owner/recipient/hash",
1536            },
1537            DomainPathCase {
1538                domain: ProtectedObjectDomain::CircleAccessEnvelope,
1539                valid: &["circles/circle/candidates/family/access-envelopes/owner/recipient/hash"],
1540                cross_domain:
1541                    "circles/circle/candidates/family/access-leaves/owner/epoch/recipient/leaf",
1542            },
1543        ];
1544
1545        for case in cases {
1546            for valid in case.valid {
1547                assert!(
1548                    validates(case.domain, valid),
1549                    "{:?} rejected {valid}",
1550                    case.domain
1551                );
1552                assert!(
1553                    !validates(case.domain, &format!("{valid}/extra")),
1554                    "{:?} accepted an extra component after {valid}",
1555                    case.domain,
1556                );
1557                let (missing, _) = valid
1558                    .rsplit_once('/')
1559                    .expect("protocol paths have more than one component");
1560                assert!(
1561                    !validates(case.domain, missing),
1562                    "{:?} accepted a missing component in {valid}",
1563                    case.domain,
1564                );
1565            }
1566            assert!(
1567                !validates(case.domain, case.cross_domain),
1568                "{:?} accepted cross-domain path {}",
1569                case.domain,
1570                case.cross_domain,
1571            );
1572        }
1573        assert!(!validates(
1574            ProtectedObjectDomain::StoreDeviceRegistration,
1575            "store-v1/devices/founder",
1576        ));
1577        assert!(!validates(
1578            ProtectedObjectDomain::StoreMembershipHead,
1579            "store-v1/membership/heads/founder/creation/1/extra",
1580        ));
1581    }
1582
1583    #[test]
1584    fn candidate_protocol_domains_reject_reordered_and_nested_components() {
1585        let cases = [
1586            (
1587                ProtectedObjectDomain::StoreCommit,
1588                "store-v1/candidates/family/commits/device/1/hash",
1589                1,
1590                3,
1591            ),
1592            (
1593                ProtectedObjectDomain::StoreDeviceSelfRetirement,
1594                "store-v1/candidates/family/device-self-retirements/device/hash",
1595                1,
1596                3,
1597            ),
1598            (
1599                ProtectedObjectDomain::StorePackage,
1600                "store-v1/candidates/family/packages/device/1/hash",
1601                1,
1602                3,
1603            ),
1604            (
1605                ProtectedObjectDomain::CirclePackage,
1606                "circles/circle/candidates/family/packages/device/1/hash",
1607                2,
1608                4,
1609            ),
1610            (
1611                ProtectedObjectDomain::CircleAccessLeaf,
1612                "circles/circle/candidates/family/access-leaves/owner/epoch/recipient/leaf",
1613                2,
1614                4,
1615            ),
1616            (
1617                ProtectedObjectDomain::CircleAccessEnvelope,
1618                "circles/circle/candidates/family/access-envelopes/owner/recipient/hash",
1619                2,
1620                4,
1621            ),
1622        ];
1623
1624        for (domain, valid, candidates_index, kind_index) in cases {
1625            let components = valid.split('/').collect::<Vec<_>>();
1626            let mut reordered = components.clone();
1627            reordered.swap(candidates_index, candidates_index + 1);
1628            let mut nested = components.clone();
1629            nested[kind_index] = "nested";
1630            nested[kind_index + 1] = components[kind_index];
1631            let mut repeated_kind = components.clone();
1632            repeated_kind[kind_index + 1] = components[kind_index];
1633            let mut empty_family = components.clone();
1634            empty_family[candidates_index + 1] = "";
1635            for malformed in [reordered, nested, repeated_kind, empty_family] {
1636                let malformed = malformed.join("/");
1637                assert!(
1638                    !validates(domain, &malformed),
1639                    "{domain:?} accepted malformed path {malformed}",
1640                );
1641            }
1642        }
1643    }
1644}