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