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