1use serde::{Deserialize, Serialize};
2
3use super::BlobScope;
4use crate::circle::{Audience, CircleId};
5use crate::objects::ExactObjectRef;
6use crate::store_commit::{ObjectHash, StoreDeviceRegistrationRef};
7use coven_foundation::store_dir::{validate_cloud_path, validate_path_token};
8use coven_keys::encryption::KeyFingerprint;
9
10const RESERVED_READABLE_VERSION_SEGMENT: &str = ".coven-versions";
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RemoteAudience {
16 Store,
17 Circle(CircleId),
18}
19
20impl TryFrom<Audience> for RemoteAudience {
21 type Error = BlobLocatorError;
22
23 fn try_from(value: Audience) -> Result<Self, Self::Error> {
24 match value {
25 Audience::Store => Ok(Self::Store),
26 Audience::Circle(circle_id) => Ok(Self::Circle(circle_id)),
27 Audience::Local => Err(BlobLocatorError::LocalAudience),
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34#[serde(tag = "protection", rename_all = "snake_case", deny_unknown_fields)]
35pub enum BlobLocator {
36 Opaque {
37 namespace: String,
38 blob_id: String,
39 uploader: StoreDeviceRegistrationRef,
40 audience: RemoteAudience,
41 scope: BlobScope,
42 key_fingerprint: KeyFingerprint,
43 plaintext_size: u64,
44 plaintext_hash: ObjectHash,
45 },
46 Browsable {
47 namespace: String,
48 blob_id: String,
49 uploader: StoreDeviceRegistrationRef,
50 cloud_path: String,
51 plaintext_size: u64,
52 plaintext_hash: ObjectHash,
53 },
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57#[serde(deny_unknown_fields)]
58pub struct StoredBlobRef {
59 locator: BlobLocator,
60 object: ExactObjectRef,
61}
62
63impl StoredBlobRef {
64 pub fn new(locator: BlobLocator, object: ExactObjectRef) -> Result<Self, BlobLocatorError> {
65 let stored = Self { locator, object };
66 stored.validate()?;
67 Ok(stored)
68 }
69
70 pub fn locator(&self) -> &BlobLocator {
71 &self.locator
72 }
73
74 pub fn object(&self) -> &ExactObjectRef {
75 &self.object
76 }
77
78 fn validate(&self) -> Result<(), BlobLocatorError> {
79 self.locator.validate()?;
80 let expected = self.locator.semantic_key();
81 if self.object.slot().logical_key() != expected {
82 return Err(BlobLocatorError::ObjectKeyMismatch {
83 expected,
84 actual: self.object.slot().logical_key().to_string(),
85 });
86 }
87 Ok(())
88 }
89}
90
91impl<'de> Deserialize<'de> for StoredBlobRef {
92 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93 where
94 D: serde::Deserializer<'de>,
95 {
96 #[derive(Deserialize)]
97 #[serde(deny_unknown_fields)]
98 struct Fields {
99 locator: BlobLocator,
100 object: ExactObjectRef,
101 }
102
103 let fields = Fields::deserialize(deserializer)?;
104 Self::new(fields.locator, fields.object).map_err(serde::de::Error::custom)
105 }
106}
107
108impl<'de> Deserialize<'de> for BlobLocator {
109 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
110 where
111 D: serde::Deserializer<'de>,
112 {
113 #[derive(Deserialize)]
114 #[serde(tag = "protection", rename_all = "snake_case", deny_unknown_fields)]
115 enum Fields {
116 Opaque {
117 namespace: String,
118 blob_id: String,
119 uploader: StoreDeviceRegistrationRef,
120 audience: RemoteAudience,
121 scope: BlobScope,
122 key_fingerprint: KeyFingerprint,
123 plaintext_size: u64,
124 plaintext_hash: ObjectHash,
125 },
126 Browsable {
127 namespace: String,
128 blob_id: String,
129 uploader: StoreDeviceRegistrationRef,
130 cloud_path: String,
131 plaintext_size: u64,
132 plaintext_hash: ObjectHash,
133 },
134 }
135
136 let locator = match Fields::deserialize(deserializer)? {
137 Fields::Opaque {
138 namespace,
139 blob_id,
140 uploader,
141 audience,
142 scope,
143 key_fingerprint,
144 plaintext_size,
145 plaintext_hash,
146 } => Self::Opaque {
147 namespace,
148 blob_id,
149 uploader,
150 audience,
151 scope,
152 key_fingerprint,
153 plaintext_size,
154 plaintext_hash,
155 },
156 Fields::Browsable {
157 namespace,
158 blob_id,
159 uploader,
160 cloud_path,
161 plaintext_size,
162 plaintext_hash,
163 } => Self::Browsable {
164 namespace,
165 blob_id,
166 uploader,
167 cloud_path,
168 plaintext_size,
169 plaintext_hash,
170 },
171 };
172 locator.validate().map_err(serde::de::Error::custom)?;
173 Ok(locator)
174 }
175}
176
177impl BlobLocator {
178 #[allow(clippy::too_many_arguments)]
179 pub fn opaque(
180 namespace: impl Into<String>,
181 blob_id: impl Into<String>,
182 uploader: StoreDeviceRegistrationRef,
183 audience: RemoteAudience,
184 scope: BlobScope,
185 key_fingerprint: KeyFingerprint,
186 plaintext_size: u64,
187 plaintext_hash: ObjectHash,
188 ) -> Result<Self, BlobLocatorError> {
189 let namespace = namespace.into();
190 let blob_id = blob_id.into();
191 let locator = Self::Opaque {
192 namespace,
193 blob_id,
194 uploader,
195 audience,
196 scope,
197 key_fingerprint,
198 plaintext_size,
199 plaintext_hash,
200 };
201 locator.validate()?;
202 Ok(locator)
203 }
204
205 pub fn browsable(
206 namespace: impl Into<String>,
207 blob_id: impl Into<String>,
208 uploader: StoreDeviceRegistrationRef,
209 cloud_path: impl Into<String>,
210 plaintext_size: u64,
211 plaintext_hash: ObjectHash,
212 ) -> Result<Self, BlobLocatorError> {
213 let namespace = namespace.into();
214 let blob_id = blob_id.into();
215 let cloud_path = cloud_path.into();
216 let locator = Self::Browsable {
217 namespace,
218 blob_id,
219 uploader,
220 cloud_path,
221 plaintext_size,
222 plaintext_hash,
223 };
224 locator.validate()?;
225 Ok(locator)
226 }
227
228 pub fn parse(bytes: &[u8]) -> Result<Self, BlobLocatorError> {
229 let locator: Self = serde_json::from_slice(bytes).map_err(BlobLocatorError::Json)?;
230 locator.validate()?;
231 if locator.to_bytes() != bytes {
232 return Err(BlobLocatorError::NonCanonicalEncoding);
233 }
234 Ok(locator)
235 }
236
237 pub fn to_bytes(&self) -> Vec<u8> {
238 serde_json::to_vec(self).expect("BlobLocator serialization cannot fail")
239 }
240
241 pub fn locator_hash(&self) -> ObjectHash {
242 ObjectHash::digest(&self.to_bytes())
243 }
244
245 pub fn semantic_key(&self) -> String {
246 match self {
247 Self::Opaque { namespace, .. } => {
248 format!("{namespace}/opaque/{}", self.locator_hash())
249 }
250 Self::Browsable {
251 namespace,
252 cloud_path,
253 ..
254 } => format!(
255 "{namespace}/readable/{cloud_path}/{RESERVED_READABLE_VERSION_SEGMENT}/{}",
256 self.locator_hash()
257 ),
258 }
259 }
260
261 pub fn is_sealed(&self) -> bool {
265 matches!(self, Self::Opaque { .. })
266 }
267
268 pub fn namespace(&self) -> &str {
269 match self {
270 Self::Opaque { namespace, .. } | Self::Browsable { namespace, .. } => namespace,
271 }
272 }
273
274 pub fn blob_id(&self) -> &str {
275 match self {
276 Self::Opaque { blob_id, .. } | Self::Browsable { blob_id, .. } => blob_id,
277 }
278 }
279
280 pub fn uploader(&self) -> &StoreDeviceRegistrationRef {
281 match self {
282 Self::Opaque { uploader, .. } | Self::Browsable { uploader, .. } => uploader,
283 }
284 }
285
286 pub fn audience(&self) -> RemoteAudience {
287 match self {
288 Self::Opaque { audience, .. } => audience.clone(),
289 Self::Browsable { .. } => RemoteAudience::Store,
290 }
291 }
292
293 pub fn plaintext_size(&self) -> u64 {
294 match self {
295 Self::Opaque { plaintext_size, .. } | Self::Browsable { plaintext_size, .. } => {
296 *plaintext_size
297 }
298 }
299 }
300
301 pub fn plaintext_hash(&self) -> ObjectHash {
302 match self {
303 Self::Opaque { plaintext_hash, .. } | Self::Browsable { plaintext_hash, .. } => {
304 *plaintext_hash
305 }
306 }
307 }
308
309 pub fn scope(&self) -> Option<&BlobScope> {
310 match self {
311 Self::Opaque { scope, .. } => Some(scope),
312 Self::Browsable { .. } => None,
313 }
314 }
315
316 pub fn key_fingerprint(&self) -> Option<KeyFingerprint> {
317 match self {
318 Self::Opaque {
319 key_fingerprint, ..
320 } => Some(*key_fingerprint),
321 Self::Browsable { .. } => None,
322 }
323 }
324
325 pub fn cloud_path(&self) -> Option<&str> {
326 match self {
327 Self::Opaque { .. } => None,
328 Self::Browsable { cloud_path, .. } => Some(cloud_path),
329 }
330 }
331
332 pub fn validate(&self) -> Result<(), BlobLocatorError> {
333 match self {
334 Self::Opaque {
335 namespace, blob_id, ..
336 } => {
337 validate_namespace(namespace)?;
338 validate_blob_id(blob_id)?;
339 }
340 Self::Browsable {
341 namespace,
342 blob_id,
343 cloud_path,
344 ..
345 } => {
346 validate_namespace(namespace)?;
347 validate_blob_id(blob_id)?;
348 validate_readable_path(cloud_path)?;
349 }
350 }
351 Ok(())
352 }
353}
354
355fn validate_blob_id(blob_id: &str) -> Result<(), BlobLocatorError> {
356 validate_path_token(blob_id).map_err(|source| BlobLocatorError::UnsafeBlobId {
357 value: blob_id.to_string(),
358 source,
359 })
360}
361
362fn validate_readable_path(cloud_path: &str) -> Result<(), BlobLocatorError> {
363 validate_cloud_path(cloud_path).map_err(|source| BlobLocatorError::UnsafeCloudPath {
364 value: cloud_path.to_string(),
365 source,
366 })?;
367 if cloud_path
368 .split('/')
369 .any(|segment| segment == RESERVED_READABLE_VERSION_SEGMENT)
370 {
371 return Err(BlobLocatorError::ReservedCloudPath {
372 value: cloud_path.to_string(),
373 });
374 }
375 Ok(())
376}
377
378fn validate_namespace(namespace: &str) -> Result<(), BlobLocatorError> {
379 validate_path_token(namespace).map_err(|source| BlobLocatorError::UnsafeNamespace {
380 value: namespace.to_string(),
381 source,
382 })
383}
384
385#[derive(Debug, thiserror::Error)]
386pub enum BlobLocatorError {
387 #[error("Local audience has no blob locator")]
388 LocalAudience,
389 #[error("unsafe blob namespace {value:?}: {source}")]
390 UnsafeNamespace {
391 value: String,
392 #[source]
393 source: coven_foundation::store_dir::PathTokenError,
394 },
395 #[error("unsafe blob id {value:?}: {source}")]
396 UnsafeBlobId {
397 value: String,
398 #[source]
399 source: coven_foundation::store_dir::PathTokenError,
400 },
401 #[error("unsafe readable blob path {value:?}: {source}")]
402 UnsafeCloudPath {
403 value: String,
404 #[source]
405 source: coven_foundation::store_dir::PathTokenError,
406 },
407 #[error("readable blob path uses reserved segment: {value:?}")]
408 ReservedCloudPath { value: String },
409 #[error("stored blob object key mismatch: expected {expected:?}, found {actual:?}")]
410 ObjectKeyMismatch { expected: String, actual: String },
411 #[error("malformed blob locator: {0}")]
412 Json(#[source] serde_json::Error),
413 #[error("blob locator bytes are not canonical")]
414 NonCanonicalEncoding,
415}
416
417#[cfg(test)]
418mod tests {
419 use super::BlobScope;
420 use super::*;
421 use crate::circle::{Audience, CircleId};
422 use crate::objects::ExactObjectRef;
423 use crate::objects::ObjectSlot;
424 use crate::store_commit::{ObjectHash, StoreDeviceRegistrationRef};
425 use coven_keys::encryption::KeyFingerprint;
426
427 fn hash(bytes: &[u8]) -> ObjectHash {
428 ObjectHash::digest(bytes)
429 }
430
431 fn uploader() -> StoreDeviceRegistrationRef {
432 let bytes = b"blob-locator uploader registration";
433 StoreDeviceRegistrationRef {
434 device_id: "11".repeat(32).parse().unwrap(),
435 registration_hash: hash(bytes),
436 object: ExactObjectRef::new(
437 ObjectSlot::logical("store-v1/devices/blob-locator-uploader.json".to_string())
438 .unwrap(),
439 bytes.len() as u64,
440 hash(bytes),
441 ),
442 }
443 }
444
445 fn stored(locator: BlobLocator) -> StoredBlobRef {
446 let key = locator.semantic_key();
447 StoredBlobRef::new(
448 locator,
449 ExactObjectRef::new(ObjectSlot::logical(key).unwrap(), 4, hash(b"body")),
450 )
451 .unwrap()
452 }
453
454 #[test]
455 fn opaque_locator_round_trips_canonical_bytes_and_path() {
456 let uploader = uploader();
457 let uploader_json = serde_json::to_string(&uploader).unwrap();
458 let locator = BlobLocator::opaque(
459 "covers",
460 "a1b2-blob",
461 uploader,
462 RemoteAudience::Circle(CircleId::from_bytes([3; 16])),
463 BlobScope::Derived("release-a".to_string()),
464 KeyFingerprint::from_bytes([4; 32]),
465 27,
466 hash(b"cover"),
467 )
468 .expect("build locator");
469
470 assert_eq!(
471 locator.semantic_key(),
472 format!("covers/opaque/{}", locator.locator_hash())
473 );
474 assert_eq!(BlobLocator::parse(&locator.to_bytes()).unwrap(), locator);
475 assert_eq!(
476 String::from_utf8(locator.to_bytes()).unwrap(),
477 format!(
478 "{{\"protection\":\"opaque\",\"namespace\":\"covers\",\"blob_id\":\"a1b2-blob\",\"uploader\":{uploader_json},\"audience\":{{\"circle\":\"{}\"}},\"scope\":{{\"Derived\":\"release-a\"}},\"key_fingerprint\":\"0404040404040404040404040404040404040404040404040404040404040404\",\"plaintext_size\":27,\"plaintext_hash\":\"{}\"}}",
479 CircleId::from_bytes([3; 16]),
480 hash(b"cover"),
481 )
482 );
483 }
484
485 #[test]
486 fn browsable_locator_round_trips_canonical_bytes_and_version_path() {
487 let locator = BlobLocator::browsable(
488 "audio",
489 "abcd-track",
490 uploader(),
491 "Artist/Album/01 Track.flac",
492 91,
493 hash(b"track"),
494 )
495 .expect("build locator");
496
497 assert_eq!(
498 locator.semantic_key(),
499 format!(
500 "audio/readable/Artist/Album/01 Track.flac/.coven-versions/{}",
501 locator.locator_hash()
502 )
503 );
504 assert_eq!(BlobLocator::parse(&locator.to_bytes()).unwrap(), locator);
505 }
506
507 #[test]
508 fn stored_blob_ref_rejects_an_object_from_another_locator() {
509 let locator = BlobLocator::opaque(
510 "covers",
511 "a1b2-blob",
512 uploader(),
513 RemoteAudience::Store,
514 BlobScope::Master,
515 KeyFingerprint::from_bytes([4; 32]),
516 4,
517 hash(b"body"),
518 )
519 .unwrap();
520 let wrong = ExactObjectRef::new(
521 ObjectSlot::logical("covers/opaque/wrong.enc".to_string()).unwrap(),
522 4,
523 hash(b"body"),
524 );
525
526 assert!(matches!(
527 StoredBlobRef::new(locator.clone(), wrong),
528 Err(BlobLocatorError::ObjectKeyMismatch { .. })
529 ));
530 let valid = stored(locator);
531 let bytes = serde_json::to_vec(&valid).unwrap();
532 assert_eq!(
533 serde_json::from_slice::<StoredBlobRef>(&bytes).unwrap(),
534 valid
535 );
536 }
537
538 #[test]
539 fn locator_rejects_unsafe_reserved_and_noncanonical_paths() {
540 assert!(matches!(
541 BlobLocator::opaque(
542 "../covers",
543 "a1b2-blob",
544 uploader(),
545 RemoteAudience::Store,
546 BlobScope::Master,
547 KeyFingerprint::from_bytes([4; 32]),
548 1,
549 hash(b"x"),
550 ),
551 Err(BlobLocatorError::UnsafeNamespace { .. })
552 ));
553 assert!(matches!(
554 BlobLocator::browsable(
555 "audio",
556 "abcd-track",
557 uploader(),
558 "Artist/.coven-versions/track.flac",
559 1,
560 hash(b"x"),
561 ),
562 Err(BlobLocatorError::ReservedCloudPath { .. })
563 ));
564
565 for cloud_path in [
566 "C:/Music/track.flac",
567 "Artist/Album/C:track.flac",
568 "Artist/../track.flac",
569 "Artist/./track.flac",
570 "Artist//track.flac",
571 "Artist/track.flac/",
572 "Artist\\track.flac",
573 "Artist/track\0.flac",
574 ] {
575 assert!(matches!(
576 BlobLocator::browsable(
577 "audio",
578 "abcd-track",
579 uploader(),
580 cloud_path,
581 1,
582 hash(b"x"),
583 ),
584 Err(BlobLocatorError::UnsafeCloudPath { .. })
585 ));
586 }
587
588 let locator = BlobLocator::opaque(
589 "covers",
590 "a1b2-blob",
591 uploader(),
592 RemoteAudience::Store,
593 BlobScope::Master,
594 KeyFingerprint::from_bytes([4; 32]),
595 1,
596 hash(b"x"),
597 )
598 .unwrap();
599 let mut value: serde_json::Value = serde_json::from_slice(&locator.to_bytes()).unwrap();
600 value["semantic_key"] = serde_json::json!("covers/opaque/relocated");
601 assert!(matches!(
602 BlobLocator::parse(&serde_json::to_vec(&value).unwrap()),
603 Err(BlobLocatorError::Json(_))
604 ));
605 }
606
607 #[test]
608 fn locator_rejects_unknown_shape_and_noncanonical_bytes() {
609 let locator = BlobLocator::opaque(
610 "covers",
611 "a1b2-blob",
612 uploader(),
613 RemoteAudience::Store,
614 BlobScope::Master,
615 KeyFingerprint::from_bytes([4; 32]),
616 1,
617 hash(b"x"),
618 )
619 .unwrap();
620 let bytes = locator.to_bytes();
621
622 let mut unknown_field: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
623 unknown_field["unknown"] = serde_json::json!(true);
624 assert!(matches!(
625 BlobLocator::parse(&serde_json::to_vec(&unknown_field).unwrap()),
626 Err(BlobLocatorError::Json(_))
627 ));
628
629 let mut unknown_variant: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
630 unknown_variant["protection"] = serde_json::json!("unknown");
631 assert!(matches!(
632 BlobLocator::parse(&serde_json::to_vec(&unknown_variant).unwrap()),
633 Err(BlobLocatorError::Json(_))
634 ));
635
636 let mut noncanonical = bytes.clone();
637 noncanonical.push(b'\n');
638 assert!(matches!(
639 BlobLocator::parse(&noncanonical),
640 Err(BlobLocatorError::NonCanonicalEncoding)
641 ));
642 }
643
644 #[test]
645 fn locator_hash_is_the_digest_of_canonical_bytes() {
646 let locator = BlobLocator::browsable(
647 "audio",
648 "abcd-track",
649 uploader(),
650 "Artist/Album/track.flac",
651 91,
652 hash(b"track"),
653 )
654 .unwrap();
655
656 assert_eq!(locator.locator_hash(), hash(&locator.to_bytes()));
657 }
658
659 #[test]
660 fn locator_parse_rejects_malformed_uploader_registration_reference() {
661 let locator = BlobLocator::opaque(
662 "covers",
663 "a1b2-blob",
664 uploader(),
665 RemoteAudience::Store,
666 BlobScope::Master,
667 KeyFingerprint::from_bytes([4; 32]),
668 1,
669 hash(b"x"),
670 )
671 .unwrap();
672 let mut value: serde_json::Value = serde_json::from_slice(&locator.to_bytes()).unwrap();
673 value["uploader"]["device_id"] = serde_json::json!("AA".repeat(32));
674 assert!(matches!(
675 BlobLocator::parse(&serde_json::to_vec(&value).unwrap()),
676 Err(BlobLocatorError::Json(_))
677 ));
678 }
679
680 #[test]
681 fn local_audience_has_no_locator_variant() {
682 assert!(RemoteAudience::try_from(Audience::Local).is_err());
683 assert_eq!(
684 RemoteAudience::try_from(Audience::Store).unwrap(),
685 RemoteAudience::Store
686 );
687 }
688}
689
690#[cfg(test)]
691mod row_upload_identity_tests {
692 use super::*;
693 use crate::blob::{locator_is_this_rows_upload, BlobRef, CacheFill, Provenance};
694 use crate::objects::{ExactObjectRef, ObjectSlot};
695 use crate::store_commit::StoreDeviceRegistrationRef;
696
697 fn hash(bytes: &[u8]) -> ObjectHash {
698 ObjectHash::digest(bytes)
699 }
700
701 fn uploader() -> StoreDeviceRegistrationRef {
702 let bytes = b"row upload identity uploader registration";
703 StoreDeviceRegistrationRef {
704 device_id: "11".repeat(32).parse().unwrap(),
705 registration_hash: hash(bytes),
706 object: ExactObjectRef::new(
707 ObjectSlot::logical("store-v1/devices/row-upload-identity.json".to_string())
708 .unwrap(),
709 bytes.len() as u64,
710 hash(bytes),
711 ),
712 }
713 }
714
715 fn row_blob() -> BlobRef {
716 BlobRef {
717 namespace: "release_files".to_string(),
718 id: "03b1d792".to_string(),
719 scope: BlobScope::Master,
720 cloud_path: None,
721 fill: CacheFill::CacheLazy,
722 provenance: Provenance::UserProvided,
723 }
724 }
725
726 fn sealed_under(fingerprint: [u8; 32], audience: RemoteAudience) -> BlobLocator {
727 BlobLocator::opaque(
728 "release_files",
729 "03b1d792",
730 uploader(),
731 audience,
732 BlobScope::Master,
733 KeyFingerprint::from_bytes(fingerprint),
734 13_205_924,
735 hash(b"the row's plaintext"),
736 )
737 .unwrap()
738 }
739
740 #[test]
749 fn a_key_rotation_does_not_re_identify_a_blob_already_uploaded() {
750 let blob = row_blob();
751 let before_rotation = sealed_under([1; 32], RemoteAudience::Store);
752 let after_rotation = sealed_under([2; 32], RemoteAudience::Store);
753
754 assert_ne!(
755 before_rotation, after_rotation,
756 "the two locators differ only in the key that sealed them",
757 );
758 for locator in [&before_rotation, &after_rotation] {
759 assert!(
760 locator_is_this_rows_upload(
761 locator,
762 &blob,
763 13_205_924,
764 hash(b"the row's plaintext"),
765 &RemoteAudience::Store,
766 ),
767 "a generation change is not a different blob",
768 );
769 }
770 }
771
772 #[test]
775 fn a_moved_audience_or_changed_content_is_not_this_rows_upload() {
776 let blob = row_blob();
777 let circle = crate::circle::CircleId::from_bytes([7; 16]);
778
779 assert!(
780 !locator_is_this_rows_upload(
781 &sealed_under([1; 32], RemoteAudience::Circle(circle)),
782 &blob,
783 13_205_924,
784 hash(b"the row's plaintext"),
785 &RemoteAudience::Store,
786 ),
787 "a Circle blob is not the Store audience's upload",
788 );
789 assert!(
790 !locator_is_this_rows_upload(
791 &sealed_under([1; 32], RemoteAudience::Store),
792 &blob,
793 13_205_924,
794 hash(b"other bytes"),
795 &RemoteAudience::Store,
796 ),
797 "different plaintext is a different blob",
798 );
799 }
800}