Skip to main content

coven_protocol/blob/
locator.rs

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/// A cloud-backed audience. Local rows have no blob locator.
13#[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/// The exact semantic cloud slot and verification facts committed with one row.
33#[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    /// Whether the stored object is sealed — an opaque home's blob, carrying a
262    /// per-chunk authenticator. A browsable home stores the plaintext verbatim,
263    /// so its object can only be verified as a whole against the row's hash.
264    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    /// A blob's stored locator names the key generation that sealed its bytes.
741    /// Rotating the Store key changes what a *new* upload would be sealed
742    /// under; it does not re-identify bytes already at the provider.
743    ///
744    /// Comparing a stored locator against one minted under today's key says the
745    /// opposite, and that answer wedges the Store: a snapshot after any
746    /// rotation asks the publisher to re-upload every pre-rotation blob, and a
747    /// user-provided blob has no local file left to re-upload from.
748    #[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    /// The rule still refuses what a re-seal really is: an audience move, or
773    /// bytes that are not this row's.
774    #[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}