Skip to main content

coven_protocol/
remote_object.rs

1//! Closed local publication and ownership state for remote protocol objects.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use super::circle::CircleId;
8use super::store_commit::{
9    CandidateFamilyId, CircleAckRef, ObjectHash, StoreBatchCommitRef, StreamActivationId,
10};
11use crate::objects::ExactObjectRef;
12
13mod construction;
14use nonactivation::validate_nonactivations;
15mod domains;
16mod graph;
17mod identity;
18mod lifecycle;
19mod nonactivation;
20mod ownership;
21mod pending_release;
22mod reclaim;
23
24pub use domains::{
25    CandidateExclusiveObjectDomain, CandidateExclusiveTarget, ProtocolInertObject,
26    RetainedAuthorityObjectDomain, RetainedAuthorityObjectRef, SharedLiveSetObjectDomain,
27    SharedLiveSetObjectRef,
28};
29pub use graph::{CandidateObjectGraph, CandidateObjectMaterial};
30pub use nonactivation::{CandidateNonactivation, CandidateNonactivationProof};
31pub use ownership::{
32    CandidateOwnership, OwnedObjectState, PendingCandidateOwnership, RetainedReplayOwner,
33    SharedObjectOwner, SharedObjectOwnership, SnapshotObjectOwner,
34};
35pub use pending_release::PendingCandidateRelease;
36
37const REMOTE_OBJECT_ID_DOMAIN: &[u8] = b"coven.remote-object-id.v1\0";
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case", deny_unknown_fields)]
41pub enum RemoteObjectRecord {
42    CandidateCommit(CandidateCommitRecord),
43    CandidateExclusive(CandidateObjectRecord),
44    RetainedAuthority(RetainedAuthorityRecord),
45    SharedLiveSet(SharedObjectRecord),
46}
47
48impl RemoteObjectRecord {}
49
50pub fn remote_object_id(object: &ExactObjectRef) -> ObjectHash {
51    let mut material = REMOTE_OBJECT_ID_DOMAIN.to_vec();
52    material.extend(serde_json::to_vec(object).expect("ExactObjectRef serialization cannot fail"));
53    ObjectHash::digest(&material)
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct CandidateObjectRecord {
59    pub identity: CandidateExclusiveTarget,
60    pub payloads: RemoteObjectPayloads,
61    pub state: CandidateObjectState,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct CandidateCommitRecord {
67    pub identity: StoreBatchCommitRef,
68    /// The digest of the commit's canonical signed bytes, which is the name
69    /// their payload file carries. A commit reference names the commit by its
70    /// signed-body hash and its stored object, neither of which is the digest
71    /// of the bytes as serialized, so the record carries it.
72    pub semantic_hash: ObjectHash,
73    pub payloads: RemoteObjectPayloads,
74    pub state: CandidateCommitState,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct RetainedAuthorityRecord {
80    pub identity: RetainedAuthorityObjectRef,
81    pub payloads: RemoteObjectPayloads,
82    pub state: RetainedAuthorityObjectState,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case", deny_unknown_fields)]
87pub enum RetainedAuthorityObjectState {
88    Prepared {
89        ownership: PendingCandidateOwnership,
90    },
91    UploadedVerified {
92        ownership: CandidateOwnership,
93    },
94}
95
96impl RetainedAuthorityObjectState {
97    pub fn validate(&self) -> Result<(), RemoteObjectRecordError> {
98        match self {
99            Self::Prepared { ownership } => ownership.validate(),
100            Self::UploadedVerified { ownership } => ownership.validate(),
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct SharedObjectRecord {
108    pub identity: SharedLiveSetObjectRef,
109    pub payloads: RemoteObjectPayloads,
110    pub state: OwnedObjectState,
111}
112
113/// Where a remote object's payloads are, and what upload that implies.
114///
115/// A stored blob's row rides inside published snapshot and bootstrap images,
116/// where a restoring device holds the row but none of the writing device's
117/// payload spool, so it carries its locator in the row. Every other domain is
118/// read only on the device that wrote it and names its payloads in the spool:
119/// the plaintext under the identity's semantic hash, the ciphertext under the
120/// exact object's stored hash. Neither hash is repeated here — the identity
121/// already names both, and a second copy would be a second thing to keep in
122/// agreement.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case", deny_unknown_fields)]
125pub enum RemoteObjectPayloads {
126    /// Plaintext and ciphertext both in the spool. This device uploads the
127    /// ciphertext from there.
128    SpooledInline,
129    /// The ciphertext was created outside this record — a staged image, or a
130    /// package this device observed rather than sealed — so this record never
131    /// uploads it. The plaintext is in the spool, except for the image domains,
132    /// which have no plaintext at all.
133    SpooledExternal,
134    /// The blob locator, in the row. The body is in the blob store and the
135    /// device uploads it from its blob spool.
136    RowBlob { locator_bytes: Vec<u8> },
137}
138
139impl RemoteObjectPayloads {
140    /// The locator a stored blob's row carries, and nothing for the domains
141    /// whose payloads are in the spool.
142    pub fn carried_locator_bytes(&self) -> Option<&[u8]> {
143        match self {
144            Self::RowBlob { locator_bytes } => Some(locator_bytes),
145            Self::SpooledInline | Self::SpooledExternal => None,
146        }
147    }
148}
149
150/// One closed remote object and the payload bytes its row will name.
151///
152/// A record holds references into the payload spool, so a record on its own is
153/// not yet something a row can name — the files have to be there first. This
154/// carries both from the moment the record is closed to the transaction that
155/// installs the files and writes the row, and the map is keyed by exactly the
156/// hashes the record claims, so a claim whose bytes are missing cannot be
157/// written down.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ClosedRemoteObject {
160    record: RemoteObjectRecord,
161    payloads: BTreeMap<ObjectHash, Vec<u8>>,
162}
163
164impl ClosedRemoteObject {
165    /// A record whose payloads are named by other rows: a stored blob, whose
166    /// body is in the blob store, or an image, whose bytes are staged by the
167    /// flow that built it.
168    pub(crate) fn carried(record: RemoteObjectRecord) -> Result<Self, RemoteObjectRecordError> {
169        Self::with_payloads(record, BTreeMap::new())
170    }
171
172    /// Close a record with the plaintext and ciphertext its spool claims name.
173    /// The exact object verifies the ciphertext here, so every constructor uses
174    /// the same payload assembly and stored-byte check.
175    fn with_spooled_payloads(
176        record: RemoteObjectRecord,
177        canonical_semantic_bytes: &[u8],
178        stored_bytes: &[u8],
179    ) -> Result<Self, RemoteObjectRecordError> {
180        let mut payloads = BTreeMap::new();
181        if let SemanticPayload::Spooled(hash) = record.semantic_payload() {
182            payloads.insert(hash, canonical_semantic_bytes.to_vec());
183        }
184        if let Some(hash) = record.stored_payload() {
185            record.object().verify(stored_bytes)?;
186            payloads.insert(hash, stored_bytes.to_vec());
187        }
188        Self::with_payloads(record, payloads)
189    }
190
191    /// A record and the bytes for exactly the payloads it claims.
192    ///
193    /// Used both when a record is first closed and when one is read back from
194    /// its row alongside its spool files. The spool names files by the digest of
195    /// their contents, so bytes found under a claimed hash are that payload; all
196    /// this has to check is that the set matches.
197    pub fn with_payloads(
198        record: RemoteObjectRecord,
199        payloads: BTreeMap<ObjectHash, Vec<u8>>,
200    ) -> Result<Self, RemoteObjectRecordError> {
201        if payloads.keys().copied().collect::<BTreeSet<_>>() != record.payload_claims() {
202            return Err(RemoteObjectRecordError::PayloadPlacement);
203        }
204        Ok(Self { record, payloads })
205    }
206
207    pub fn record(&self) -> &RemoteObjectRecord {
208        &self.record
209    }
210
211    pub fn into_record(self) -> RemoteObjectRecord {
212        self.record
213    }
214
215    /// Advance the record this holds, keeping its payloads. A transition never
216    /// changes what a record names — neither hash mutates and the domain changes
217    /// re-wrap the same reference — so the payload set carries over unchanged,
218    /// and is re-checked against the new record rather than assumed.
219    pub fn map_record(
220        self,
221        transition: impl FnOnce(
222            RemoteObjectRecord,
223        ) -> Result<RemoteObjectRecord, RemoteObjectRecordError>,
224    ) -> Result<Self, RemoteObjectRecordError> {
225        Self::with_payloads(transition(self.record)?, self.payloads)
226    }
227
228    /// The payload files this record names, by the hash each is stored under.
229    /// Named apart from the record's own [`RemoteObjectRecord::payloads`],
230    /// which says *where* the payloads are rather than carrying them.
231    pub fn payload_bytes(&self) -> &BTreeMap<ObjectHash, Vec<u8>> {
232        &self.payloads
233    }
234
235    /// The record's plaintext: the locator a stored blob's row carries, or the
236    /// spool file every other domain's identity names. `None` for the image
237    /// domains, which name their payload by reference and have no body here.
238    pub fn semantic_bytes(&self) -> Option<&[u8]> {
239        match self.record.semantic_payload() {
240            SemanticPayload::Carried(bytes) => Some(bytes),
241            SemanticPayload::Spooled(hash) => self.payloads.get(&hash).map(Vec::as_slice),
242            SemanticPayload::Absent => None,
243        }
244    }
245
246    /// The ciphertext this record uploads, for the domains that seal one.
247    pub fn stored_bytes(&self) -> Option<&[u8]> {
248        self.record
249            .stored_payload()
250            .and_then(|hash| self.payloads.get(&hash).map(Vec::as_slice))
251    }
252}
253
254impl std::ops::Deref for ClosedRemoteObject {
255    type Target = RemoteObjectRecord;
256
257    fn deref(&self) -> &Self::Target {
258        &self.record
259    }
260}
261
262/// Where one record's plaintext is: in the row, in the spool, or nowhere,
263/// because the image domains name their payload by reference and have no
264/// semantic body of their own.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum SemanticPayload<'record> {
267    Carried(&'record [u8]),
268    Spooled(ObjectHash),
269    Absent,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case", deny_unknown_fields)]
274pub enum CandidateObjectState {
275    Prepared {
276        ownership: PendingCandidateOwnership,
277    },
278    UploadedVerified {
279        ownership: PendingCandidateOwnership,
280    },
281    CleanupPending {
282        former_candidates: Vec<CandidateNonactivation>,
283    },
284    AbsentVerified {
285        former_candidates: Vec<CandidateNonactivation>,
286    },
287}
288
289impl CandidateObjectState {
290    fn validate(&self) -> Result<(), RemoteObjectRecordError> {
291        match self {
292            Self::Prepared { ownership } | Self::UploadedVerified { ownership } => {
293                ownership.validate()
294            }
295            Self::CleanupPending { former_candidates }
296            | Self::AbsentVerified { former_candidates } => {
297                validate_nonactivations(former_candidates)
298            }
299        }
300    }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case", deny_unknown_fields)]
305pub enum CandidateCommitState {
306    Prepared,
307    UploadedVerified,
308    CleanupPending { proof: CandidateNonactivationProof },
309    AbsentVerified { proof: CandidateNonactivationProof },
310}
311
312pub use super::store_commit::StoreBatchCommitDeletionTarget;
313
314#[derive(Debug, thiserror::Error)]
315pub enum RemoteObjectRecordError {
316    #[error("prepared stored bytes do not match their exact reference: {0}")]
317    Storage(#[from] crate::objects::StorageError),
318    #[error("remote object JSON: {0}")]
319    Json(#[from] serde_json::Error),
320    #[error("remote object Store protocol: {0}")]
321    StoreProtocol(#[from] crate::store_commit::StoreProtocolError),
322    #[error("remote object audience package: {0}")]
323    AudiencePackage(#[from] crate::audience_package::AudiencePackageError),
324    #[error("remote object Circle transition: {0}")]
325    CircleTransition(#[from] crate::circle_control::CircleTransitionError),
326    #[error("remote object blob locator: {0}")]
327    BlobLocator(#[from] crate::blob::locator::BlobLocatorError),
328    #[error("remote object payload placement contradicts its domain")]
329    PayloadPlacement,
330    #[error("prepared stored reference differs from the closed identity reference")]
331    StoredReferenceMismatch,
332    #[error("prepared semantic hash is {actual}, expected {expected}")]
333    SemanticHashMismatch {
334        expected: ObjectHash,
335        actual: ObjectHash,
336    },
337    #[error("pending candidate ownership has no pending candidate")]
338    EmptyPendingOwnership,
339    #[error("candidate ownership sets overlap")]
340    OverlappingOwnership,
341    #[error("candidate ownership has no pending or activated owner")]
342    EmptyOwnership,
343    #[error("prepared canonical bytes do not parse as their claimed domain: {0}")]
344    InvalidDomain(String),
345    #[error("prepared canonical bytes disagree with their claimed domain")]
346    DomainMismatch,
347    #[error("candidate object graph contains the same exact object more than once")]
348    DuplicateCandidateObject,
349    #[error("candidate object graph material is missing")]
350    CandidateObjectMissing,
351    #[error("candidate object material is outside the signed graph")]
352    CandidateObjectInvented,
353    #[error("remote object is not uploaded for the exact activating commit")]
354    InvalidActivation,
355    #[error("remote object cannot return to uploaded state after cleanup began")]
356    InvalidUploadTransition,
357    #[error("candidate nonactivation set is empty")]
358    EmptyNonactivation,
359    #[error("candidate nonactivation proof is invalid: {0}")]
360    InvalidProof(String),
361    #[error("candidate does not own this remote object")]
362    CandidateOwnerMismatch,
363    #[error("remote object does not retain this candidate's nonactivation proof")]
364    CandidateNonactivationMissing,
365    #[error("remote object is not awaiting exact candidate cleanup")]
366    InvalidCleanupTransition,
367    #[error("remote object is not the solely-owned activated Store package being reclaimed")]
368    InvalidReclaim,
369}
370
371#[cfg(test)]
372mod tests;