1use async_trait::async_trait;
8use std::path::Path;
9
10use crate::storage::cloud::{CloudHeadVersion, ObjectSlot};
11use crate::sync::store_commit::ObjectHash;
12
13#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
415pub struct StoreProtocolObjectDomain(ProtectedObjectDomain);
416
417#[derive(Clone, Copy, Debug, PartialEq, Eq)]
419pub struct CircleProtocolObjectDomain(ProtectedObjectDomain);
420
421pub 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
479pub 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#[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#[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#[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#[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#[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#[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 #[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 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 fn store_blob_protection(&self) -> Result<BlobSpoolProtection, StorageError>;
1207
1208 async fn provider_binding(&self) -> Result<ResolvedProviderBinding, StorageError>;
1211
1212 async fn allocate_protocol_slot(
1214 &self,
1215 context: &ProtocolObjectContext,
1216 semantic_prefix: &str,
1217 extension: &str,
1218 ) -> Result<ObjectSlot, StorageError>;
1219
1220 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 async fn create_protocol_object(
1232 &self,
1233 prepared: &PreparedExactObject,
1234 ) -> Result<(), StorageError>;
1235
1236 async fn read_protocol_object(
1239 &self,
1240 context: &ProtocolObjectContext,
1241 object: &ExactObjectRef,
1242 semantic_prefix: &str,
1243 ) -> Result<Vec<u8>, StorageError>;
1244
1245 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 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 async fn delete_protocol_object(&self, object: &ExactObjectRef) -> Result<(), StorageError>;
1265
1266 async fn allocate_blob_slot(
1268 &self,
1269 locator: &crate::blob::locator::BlobLocator,
1270 authority: &BlobWriteAuthority<'_>,
1271 ) -> Result<ObjectSlot, StorageError>;
1272
1273 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 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 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 async fn verify_blob_object(
1304 &self,
1305 blob: &crate::blob::locator::StoredBlobRef,
1306 ) -> Result<(), StorageError>;
1307
1308 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 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 async fn delete_blob_object(
1329 &self,
1330 blob: &crate::blob::locator::StoredBlobRef,
1331 ) -> Result<(), StorageError>;
1332
1333 async fn put_wrapped_key(
1339 &self,
1340 owner_pubkey: &str,
1341 recipient_pubkey: &str,
1342 data: Vec<u8>,
1343 ) -> Result<(), StorageError>;
1344
1345 async fn get_wrapped_key(
1352 &self,
1353 owner_pubkey: &str,
1354 recipient_pubkey: &str,
1355 ) -> Result<Vec<u8>, StorageError>;
1356
1357 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}