use std::collections::HashMap; use std::sync::LazyLock; use std::{fmt, ops::Deref, str::FromStr}; use crypto::{PublicKey, Signature}; use nonempty::NonEmpty; use radicle_cob::{Embed, ObjectId, TypeName}; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::cob::store::access::WriteAs; use crate::git; use crate::git::Oid; use crate::identity::doc::Doc; use crate::node::NodeId; use crate::storage; use crate::{ cob, cob::{ ActorId, Timestamp, Uri, op, store, store::{Cob, CobAction, Transaction}, }, identity::{ Did, doc::{DocError, RepoId}, }, storage::{ReadRepository, RepositoryError, WriteRepository}, }; use super::{Author, EntryId}; /// Type name of an identity proposal. pub static TYPENAME: LazyLock = LazyLock::new(|| FromStr::from_str("xyz.radicle.id").expect("type name is valid")); /// Identity operation. pub type Op = cob::Op; /// Identifier for an identity revision. pub type RevisionId = EntryId; pub type IdentityStream<'a> = cob::stream::Stream<'a, Action>; impl<'a> IdentityStream<'a> { pub fn init(identity: ObjectId, store: &'a storage::git::Repository) -> Self { let history = cob::stream::CobRange::new(&TYPENAME, &identity); Self::new(&store.backend, history, TYPENAME.clone()) } } /// Proposal operation. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum Action { #[serde(rename = "revision")] Revision { /// Short summary of changes. title: cob::Title, /// Longer comment on proposed changes. #[serde(default, skip_serializing_if = "String::is_empty")] description: String, /// Blob identifier of the document included in this action as an embed. /// Hence, we do not include it as a parent of this action in [`CobAction`]. blob: Oid, /// Parent revision that this revision replaces. parent: Option, /// Signature over the revision blob. signature: Signature, }, RevisionEdit { /// The revision to edit. revision: RevisionId, /// Short summary of changes. title: cob::Title, /// Longer comment on proposed changes. #[serde(default, skip_serializing_if = "String::is_empty")] description: String, }, #[serde(rename = "revision.accept")] RevisionAccept { revision: RevisionId, /// Signature over the blob. signature: Signature, }, #[serde(rename = "revision.reject")] RevisionReject { revision: RevisionId }, #[serde(rename = "revision.redact")] RevisionRedact { revision: RevisionId }, } impl CobAction for Action { fn produces_identifier(&self) -> bool { matches!(self, Self::Revision { .. }) } } /// Error applying an operation onto a state. #[non_exhaustive] #[derive(Error, Debug)] pub enum ApplyError { /// Causal dependency missing. /// /// This error indicates that the operations are not being applied /// in causal order, which is a requirement for this CRDT. /// /// For example, this can occur if an operation references another operation /// that hasn't happened yet. #[error("causal dependency {0:?} missing")] Missing(EntryId), /// General error initializing an identity. #[error("initialization failed: {0}")] Init(&'static str), /// Invalid signature over document blob. #[error("invalid signature from {0} for blob {1}")] InvalidSignature(PublicKey, Oid), /// Unauthorized action. #[error("not authorized to perform this action")] NotAuthorized, #[error("parent id is missing from revision")] MissingParent, #[error("verdict for this revision has already been applied")] DuplicateVerdict, #[error("revision is in an unexpected state")] UnexpectedState, #[error("delegate already accepted a sibling revision '{revision}'")] SiblingAccepted { revision: RevisionId }, #[error("document does not contain any changes to current identity")] DocUnchanged, #[error("git: {0}")] Git(#[from] git::raw::Error), #[error("identity document error: {0}")] Doc(#[from] DocError), #[error("{author} is not a delegate, and only delegates are allowed to {action}")] NonDelegateUnauthorized { author: Did, action: String }, } impl ApplyError { fn non_delegate_unauthorized(author: Did, action: &Action) -> Self { let action = match action { Action::Revision { .. } => "create a revision", Action::RevisionEdit { .. } => "edit a revision", Action::RevisionAccept { .. } => "accept a revision", Action::RevisionReject { .. } => "reject a revision", Action::RevisionRedact { .. } => "redact a revision", }; Self::NonDelegateUnauthorized { author, action: action.to_string(), } } } /// Error updating or creating proposals. #[derive(Error, Debug)] pub enum Error { #[error("apply failed: {0}")] Apply(#[from] ApplyError), #[error("store: {0}")] Store(#[from] store::Error), #[error("op decoding failed: {0}")] Op(#[from] op::OpEncodingError), #[error(transparent)] Doc(#[from] DocError), #[error("revision {0} was not found")] NotFound(RevisionId), } /// An evolving identity document. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Identity { /// The current revision of the document. /// Equal to the head of the identity branch. pub current: RevisionId, /// The initial revision of the document. pub root: RevisionId, /// Revisions. revisions: HashMap, /// Timeline of events. timeline: Vec, } impl cob::store::CobWithType for Identity { fn type_name() -> &'static TypeName { &TYPENAME } } impl std::ops::Deref for Identity { type Target = Revision; fn deref(&self) -> &Self::Target { self.current() } } impl Identity { pub fn new(root: Revision) -> Self { let root_id = root.id; Self { root: root_id, current: root_id, revisions: HashMap::from_iter([(root_id, root)]), timeline: vec![root_id], } } pub fn initialize<'a, 'b, Repo, Signer>( doc: &Doc, store: &'a Repo, signer: &'b Signer, ) -> Result, cob::store::Error> where Repo: WriteRepository + cob::Store, Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, Signer: crypto::signature::Signer, Signer: crypto::signature::Verifier, { let mut store = cob::store::Store::open(store, WriteAs::new(signer))?; #[allow(clippy::unwrap_used)] let title = cob::Title::new("Initial revision").unwrap(); #[allow(deprecated)] let (actions, embeds) = { let repo = store.repo(); let signer = store.signer(); Transaction::new_revision(title, "", doc, None, repo, signer)?.into_inner() }; let actions = NonEmpty::from_vec(actions) .expect("Transaction::initial: transaction must contain at least one action"); let (id, identity) = store.create("Initialize identity", actions, embeds)?; Ok(IdentityMut { id, identity, store, }) } pub fn get(object: &ObjectId, repo: &Repo) -> Result where Repo: ReadRepository + cob::Store, { use cob::store::CobWithType; cob::get::(repo, Self::type_name(), object) .map(|r| r.map(|cob| cob.object))? .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *object)) } /// Get a proposal mutably. pub fn get_mut<'a, 'b, Repo, Signer>( id: &ObjectId, repo: &'a Repo, signer: &'b Signer, ) -> Result, store::Error> where Repo: WriteRepository + cob::Store, Signer: crypto::signature::Signer, { let obj = Self::get(id, repo)?; let store = cob::store::Store::open(repo, WriteAs::new(signer))?; Ok(IdentityMut { id: *id, identity: obj, store, }) } pub fn load(repo: &R) -> Result { let oid = repo.identity_root()?; let oid = ObjectId::from(oid); Self::get(&oid, repo).map_err(RepositoryError::from) } pub fn load_mut<'a, 'b, Repo, Signer>( repo: &'a Repo, signer: &'b Signer, ) -> Result, RepositoryError> where Repo: WriteRepository + cob::Store, Signer: crypto::signature::Signer, { let oid = repo.identity_root()?; let oid = ObjectId::from(oid); Self::get_mut(&oid, repo, signer).map_err(RepositoryError::from) } } impl Identity { /// The repository identifier. #[deprecated] pub fn id(&self) -> RepoId { self.root().blob.into() } /// The current document. pub fn doc(&self) -> &Doc { &self.current().doc } /// The current revision. pub fn current(&self) -> &Revision { self.revision(&self.current) .expect("Identity::current: the current revision must always exist") } /// The initial revision of this identity. pub fn root(&self) -> &Revision { self.revision(&self.root) .expect("Identity::root: the root revision must always exist") } /// The head of the identity branch. This points to a commit that /// contains the current document blob. pub fn head(&self) -> Oid { self.current } /// A specific [`Revision`], that may be redacted. pub fn revision(&self, revision: &RevisionId) -> Option<&Revision> { let result = self.revisions.get(revision); debug_assert!(result.is_none_or(|result| &result.id == revision)); result } /// All the [`Revision`]s that have not been redacted. pub fn revisions(&self) -> impl DoubleEndedIterator { self.timeline.iter().filter_map(|id| { self.revisions .get(id) .filter(|revision| !matches!(revision.state, State::Redacted(_))) }) } pub fn latest_by(&self, who: &Did) -> Option<&Revision> { self.revisions().rev().find(|r| r.author.id() == who) } #[inline] fn children_of(&self, id: &RevisionId) -> impl Iterator { self.revision(id) .map(|revision| &revision.children) .into_iter() .flatten() } #[inline] fn siblings_of(&self, id: &RevisionId) -> impl Iterator { self.revision(id) .and_then(|revision| revision.parent.as_ref()) .map(|parent_id| { self.children_of(parent_id) .filter(move |child| *child != id) }) .into_iter() .flatten() } /// Checks if the `delegate` has already accepted any child of the provided /// `parent` that is active. /// /// The [`RevisionId`] is returned if one was found. /// /// Used to enforce the invariant that no two sibling, active [`Revision`]s /// should have an accepted vote from the same delegate. pub(crate) fn has_accepted_active_sibling( &self, parent: &RevisionId, delegate: &Did, ) -> Option { self.children_of(parent).find_map(|child_id| { self.revision(child_id) .filter(|child| child.is_active()) .and_then(|child| { child .accepted() .any(|did| did == *delegate) .then_some(*child_id) }) }) } } impl store::Cob for Identity { type Action = Action; type Error = ApplyError; fn from_root(op: Op, repo: &R) -> Result { let mut actions = op.actions.into_iter(); let Some(Action::Revision { title, description, blob, signature, parent, }) = actions.next() else { return Err(ApplyError::Init( "the first action must be of type `revision`", )); }; if parent.is_some() { return Err(ApplyError::Init( "the initial revision must not have a parent", )); } if actions.next().is_some() { return Err(ApplyError::Init( "the first operation must contain only one action", )); } let root = Doc::load_at(op.id, repo)?; if root.blob != blob { return Err(ApplyError::Init("invalid object id specified in revision")); } if root.blob != *repo.id() { return Err(ApplyError::Init( "repository root does not match identifier", )); } assert_eq!(root.commit, op.id); let founder = root.delegates().first(); if founder.as_key() != &op.author { return Err(ApplyError::Init("delegate does not match committer")); } // Verify signature against root document. Since there is no previous document, // we verify it against itself. if root .verify_signature(founder, &signature, root.blob) .is_err() { return Err(ApplyError::InvalidSignature(**founder, root.blob)); } let revision = Revision::new( root.commit, title, description, op.author.into(), root.blob, root.doc, State::Accepted, signature, parent, op.timestamp, ); Ok(Identity::new(revision)) } fn op<'a, R: ReadRepository, I: IntoIterator>( &mut self, op: Op, concurrent: I, repo: &R, ) -> Result<(), ApplyError> { let id = op.id; let concurrent = concurrent.into_iter().collect::>(); for action in op.actions { match self.action(action, id, op.author, op.timestamp, repo) { Ok(()) => {} // This particular error is returned when there is a mismatch between the expected // and the actual state of a revision, which can happen concurrently. Therefore // if there are other concurrent ops, it is not fatal and we simply ignore it. Err(ApplyError::UnexpectedState) if !concurrent.is_empty() => {} // It is not a user error if the revision happens to be redacted by // the time this action is processed. Err(other) => return Err(other), } debug_assert!(!self.timeline.contains(&id)); self.timeline.push(id); } Ok(()) } } impl Identity { /// Apply a single action to the identity document. /// /// This function ensures a few things: /// * Only delegates can interact with the state. /// * There is only ever one accepted revision; this is the "current" revision. /// * There can be zero or more active revisions, up to the number of delegates. /// * An active revision is one that can be "voted" on. /// * Only an active revision can be accepted, rejected or edited. fn action( &mut self, action: Action, id: EntryId, author: ActorId, timestamp: Timestamp, repo: &R, ) -> Result<(), ApplyError> { let did = author.into(); match action { action @ (Action::RevisionAccept { revision: id, .. } | Action::RevisionReject { revision: id }) => { let noun = match action { Action::RevisionAccept { .. } => "acceptance", Action::RevisionReject { .. } => "rejection", _ => unreachable!(), }; let revision = self.revision(&id).ok_or(ApplyError::Missing(id))?; match revision.state { state @ (State::Accepted | State::Rejected(_) | State::Redacted(_)) => { log::debug!( "Skipping {noun} of revision {id} by {did} because it already is {}.", state.display_with_reason() ); } State::Active => { let parent_id = revision.parent.ok_or(ApplyError::MissingParent)?; let parent = self .revision(&parent_id) .ok_or(ApplyError::Missing(parent_id))?; if !parent.is_delegate(&did) { return Err(ApplyError::non_delegate_unauthorized(did, &action)); } log::trace!("Applying {noun} of active revision {id} by {did}."); match action { Action::RevisionAccept { signature, .. } => { // A delegate may only accept one Active child // of a given parent. If they already accepted a // sibling, silently skip this vote. // This handles old histories where the // invariant was not enforced. if let Some(revision_id) = self.has_accepted_active_sibling(&parent_id, &did) { log::debug!( "Skipping accept of {id} by {did}: \ already accepted an active revision '{revision_id}'.", ); return Ok(()); } parent .verify_signature(&author, &signature, revision.blob) .map_err(|_source| { ApplyError::InvalidSignature(author, revision.blob) })?; if self .revision_mut(&id)? .verdicts .insert(author, Verdict::Accept(signature)) .is_some() { return Err(ApplyError::DuplicateVerdict); } self.adopt(id); } Action::RevisionReject { .. } => { let rejection_threshold = parent.delegates().len() - parent.majority(); let revision = self.revision_mut(&id)?; if revision.verdicts.insert(author, Verdict::Reject).is_some() { return Err(ApplyError::DuplicateVerdict); } if revision.rejected().count() > rejection_threshold { revision.state = State::Rejected(RejectedBy::Vote); self.cascade(id, State::Rejected(RejectedBy::Parent)) } } _ => unreachable!(), } } } } Action::RevisionEdit { title, description, revision: id, } => { let revision = self.revision_mut(&id)?; if !revision.is_active() { log::debug!("Cannot edit revision {id} because it is not active.",); return Err(ApplyError::UnexpectedState); } if revision.author.public_key() != &author { log::debug!( "{} cannot edit revision created by {}.", author, revision.author.public_key() ); // Since the author never changes, we can safely mark this as invalid. return Err(ApplyError::NotAuthorized); } revision.title = title; revision.description = description; } Action::RevisionRedact { revision: id } => { let revision = self.revision_mut(&id)?; if revision.author.public_key() != &author { log::debug!( "{author} cannot redact revision created by {}.", revision.author.public_key() ); // Since the author never changes, we can safely mark this as invalid. return Err(ApplyError::NotAuthorized); } if !revision.is_active() { log::debug!("Cannot redact inactive revision {id}."); return Ok(()); } log::debug!("Redacting revision {id}."); revision.state = State::Redacted(RedactedBy::Author); self.cascade(id, State::Redacted(RedactedBy::Parent)); } Action::Revision { title, description, blob, signature, parent: parent_id, } => { debug_assert_eq!(self.revisions.get(&id), None, "revision visited twice"); let doc = Doc::from_blob(&repo.blob(blob)?)?; // All revisions but the first one must have a parent. let parent_id = parent_id.ok_or(ApplyError::MissingParent)?; let parent = self.revision(&parent_id).ok_or(ApplyError::MissingParent)?; if !parent.is_delegate(&did) { return Err(ApplyError::NonDelegateUnauthorized { author: author.into(), action: "create a revision".to_string(), }); } // We expect the revision to make a change compared to its parent. if doc == parent.doc { return Err(ApplyError::DocUnchanged); } // Verify signature over new blob, using trusted delegates. if parent.verify_signature(&author, &signature, blob).is_err() { return Err(ApplyError::InvalidSignature(author, blob)); } // If the parent is already rejected or redacted, this revision is dead on arrival. // Furthermore, if the parent is accepted but is NO LONGER the current revision, // it means a sibling was already adopted and this is a late-arriving fork. let state = match parent.state { state @ (State::Rejected(RejectedBy::Parent) | State::Redacted(RedactedBy::Parent)) => state, State::Rejected(RejectedBy::Vote | RejectedBy::Sibling(_)) => { State::Rejected(RejectedBy::Parent) } State::Redacted(RedactedBy::Author) => State::Redacted(RedactedBy::Parent), State::Accepted => { match parent .children .iter() .find(|id| { self.revisions .get(id) .is_some_and(|r| r.state == State::Accepted) }) .copied() { Some(sibling) => { log::debug!( "Revision {id} is rejected because sibling {sibling} was already accepted.", ); State::Rejected(RejectedBy::Sibling(sibling)) } None => State::Active, } } State::Active => State::Active, }; // Check BEFORE inserting the new revision, so it doesn't count // as its own sibling. If the author already has an accept on an // Active sibling, their implicit author accept will be stripped // after creation. let should_strip_author_accept = matches!(state, State::Active) .then(|| self.has_accepted_active_sibling(&parent_id, &did)) .flatten(); let revision = Revision::new( id, title, description, author.into(), blob, doc, state, signature, Some(parent_id), timestamp, ); self.revisions.insert(id, revision); self.revision_mut(&parent_id)?.children.push(id); if let Some(revision_id) = should_strip_author_accept { log::debug!( "Stripping implicit accept from revision {id} by {did}: \ already accepted the active revision {revision_id}.", ); self.revision_mut(&id)?.verdicts.remove(&author); } if state == State::Active { self.adopt(id); } } } Ok(()) } /// Try to adopt an active revision as the current one. /// /// # Panics /// /// If the revision with the given ID is not active or lookup from /// `self.revisions` returns a revision with a different ID. /// /// If the parent revision of the revision with given ID does not exist. fn adopt(&mut self, id: RevisionId) { if self.current == id { return; } let candidate = self.revision(&id).expect("revision exists"); assert_eq!(candidate.state, State::Active); let parent = candidate.parent.expect("revision has parent"); if parent != self.current { log::debug!( "Cannot adopt revision {} because its parent {} is not the current revision {}.", id, parent, self.current ); return; } let votes = candidate.accepted().count(); if !self.is_majority(votes) { log::trace!( "Revision {} has {} votes, but needs {} to be adopted.", id, votes, self.majority() ); return; } for sibling in self.siblings_of(&id).copied().collect::>() { let Some(revision) = self.revisions.get_mut(&sibling) else { continue; }; if revision.state != State::Active { continue; } log::debug!( "Adoption of {} causes {} (a sibling) to be rejected.", id, sibling ); revision.state = State::Rejected(RejectedBy::Sibling(id)); self.cascade(sibling, State::Rejected(RejectedBy::Parent)); } self.current = id; self.revision_mut(&id) .expect("current revision exists") .state = State::Accepted; // Re-evaluate active children under the new quorum rules. // Because `self.current` just changed, the delegate list // might have changed, thus `self.majority()` might have changed. let children_to_adopt = self .children_of(&id) .filter(|child| { self.revisions.get(child).is_some_and(|r| { r.state == State::Active && self .is_majority(r.accepted().filter(|did| self.is_delegate(did)).count()) }) }) .copied() .collect::>(); // Recursively adopt any children that now meet the quorum. for child in children_to_adopt { self.adopt(child); } } /// Apply state to all active children of the given revision, recursively. fn cascade(&mut self, parent: RevisionId, state: State) { debug_assert!(matches!( state, State::Rejected(RejectedBy::Parent) | State::Redacted(RedactedBy::Parent) )); let mut descendants = self.children_of(&parent).copied().collect::>(); while let Some(next) = descendants.pop() { let Some(revision) = self.revisions.get_mut(&next) else { continue; }; if revision.state != State::Active { continue; } log::trace!( "Cascading state from {} causes {} to be {}.", parent, next, state, ); revision.state = state; descendants.extend(self.children_of(&next)); } } /// A specific [`Revision`], mutably. /// /// # Errors /// /// Returns `ApplyError::Missing` if the revision is not found. fn revision_mut(&mut self, id: &RevisionId) -> Result<&mut Revision, ApplyError> { let revision = self.revisions.get_mut(id).ok_or(ApplyError::Missing(*id)); #[cfg(debug_assertions)] if let Some(actual_id) = revision.as_ref().ok().map(|r| r.id) { debug_assert_eq!(actual_id, *id) } revision } } impl cob::Evaluate for Identity { type Error = Error; fn init(entry: &cob::Entry, repo: &R) -> Result { let op = Op::try_from(entry)?; let object = Identity::from_root(op, repo)?; Ok(object) } fn apply<'a, I: Iterator>( &mut self, entry: &cob::Entry, concurrent: I, repo: &R, ) -> Result<(), Self::Error> { let op = Op::try_from(entry)?; self.op(op, concurrent.map(|(_, e)| e), repo) .map_err(Error::Apply) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub enum Verdict { /// An accepting verdict must supply the [`Signature`] over the /// new proposed [`Doc`]. Accept(Signature), /// Rejecting the proposed [`Doc`]. Reject, } /// State of a revision. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum State { /// The initial state of any revision. /// /// If a revision receives a majority of accepting votes, it is adopted and /// transitions to [`Self::Accepted`]. Also, all its sibling revisions /// transition to [`Self::Rejected`]. /// /// If a revision receives a majority of rejecting votes, /// it transitions to [`Self::Rejected`]. This has no impact on sibling /// revisions. /// /// If a revision is redacted (this can only be done by its authoring /// delegate), it transitions to [`Self::Redacted`]. From there, no further /// state transitions are possible. This can be viewed as a form of /// withdrawal of the revision. Active, /// The revision was accepted by a majority of delegates. /// Accepted revisions cannot be redacted or rejected. Accepted, /// The revision was rejected by a majority of delegates, or /// a sibling revision was accepted by a majority of delegates or /// an ancestor was rejected. Rejected(RejectedBy), /// The author decided to redact/withdraw the revision, or /// an ancestor was redacted. Redacted(RedactedBy), } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum RejectedBy { /// Rejected due to majority of delegates rejecting this revision. Vote, /// Rejected due to the parent revision being rejected. Parent, /// Rejected due to a sibling revision being accepted. Sibling(RevisionId), } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum RedactedBy { /// Redacted by the author. Author, /// Redacted due to the parent revision being redacted. Parent, } impl std::fmt::Display for State { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Active => write!(f, "active"), Self::Accepted => write!(f, "accepted"), Self::Rejected(_) => write!(f, "rejected"), Self::Redacted(_) => write!(f, "redacted"), } } } impl std::fmt::Display for RejectedBy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RejectedBy::Vote => write!(f, "vote"), RejectedBy::Parent => write!(f, "parent"), RejectedBy::Sibling(oid) => write!(f, "sibling '{oid}'"), } } } impl std::fmt::Display for RedactedBy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RedactedBy::Author => write!(f, "author"), RedactedBy::Parent => write!(f, "parent"), } } } impl State { /// The implementation of [`std::fmt::Display`] for [`State`] only displays /// the state itself, but in some contexts it is useful to also display the /// reason for rejection or redaction, if applicable. /// This function returns a [`std::fmt::Display`] implementation that /// includes the reason for [`Self::Rejected`] or [`Self::Redacted`]. pub fn display_with_reason(&self) -> impl std::fmt::Display { const BY: &str = "by"; match self { Self::Active | Self::Accepted => self.to_string(), Self::Rejected(by) => format!("{self} {BY} {by}"), Self::Redacted(by) => format!("{self} {BY} {by}"), } } } /// A new [`Doc`] for an [`Identity`]. The revision can be /// reviewed by gathering [`Signature`]s for accepting the changes, or /// rejecting them. /// /// Once a revision has reached the quorum threshold of the previous /// [`Identity`] it is then adopted as the current identity. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct Revision { /// The id of this revision. Points to a commit. pub id: RevisionId, /// Identity document blob at this revision. pub blob: Oid, /// Title of the proposal. pub title: cob::Title, /// State of the revision. pub state: State, /// Description of the proposal. pub description: String, /// Author of this proposed revision. pub author: Author, /// New [`Doc`] that will replace `previous`' document. pub doc: Doc, /// Physical timestamp of this proposal revision. pub timestamp: Timestamp, /// Parent revision. pub parent: Option, /// Signatures and rejections given by the delegates. verdicts: HashMap, /// Children of this revision. children: Vec, } impl std::ops::Deref for Revision { type Target = Doc; fn deref(&self) -> &Self::Target { &self.doc } } impl Revision { pub fn signatures(&self) -> impl Iterator { self.verdicts().filter_map(|(key, verdict)| match verdict { Verdict::Accept(sig) => Some((key, *sig)), Verdict::Reject => None, }) } pub fn is_accepted(&self) -> bool { matches!(self.state, State::Accepted) } pub fn is_active(&self) -> bool { matches!(self.state, State::Active) } pub fn verdicts(&self) -> impl Iterator { self.verdicts.iter() } pub fn accepted(&self) -> impl Iterator + '_ { self.signatures().map(|(key, _)| key.into()) } pub fn rejected(&self) -> impl Iterator + '_ { self.verdicts().filter_map(|(key, v)| match v { Verdict::Accept(_) => None, Verdict::Reject => Some(key.into()), }) } pub fn sign>( &self, signer: &G, ) -> Result { self.doc.signature_of(signer) } } // Private functions that may not do all the verification. Use with caution. impl Revision { fn new( id: RevisionId, title: cob::Title, description: String, author: Author, blob: Oid, doc: Doc, state: State, signature: Signature, parent: Option, timestamp: Timestamp, ) -> Self { let verdicts = HashMap::from_iter([(*author.public_key(), Verdict::Accept(signature))]); Self { id, title, description, author, blob, doc, state, verdicts, parent, children: Vec::new(), timestamp, } } } impl store::Transaction { pub fn accept( &mut self, revision: RevisionId, signature: Signature, ) -> Result<(), store::Error> { self.push(Action::RevisionAccept { revision, signature, }) } pub fn reject(&mut self, revision: RevisionId) -> Result<(), store::Error> { self.push(Action::RevisionReject { revision }) } pub fn edit( &mut self, revision: RevisionId, title: cob::Title, description: impl ToString, ) -> Result<(), store::Error> { self.push(Action::RevisionEdit { revision, title, description: description.to_string(), }) } pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> { self.push(Action::RevisionRedact { revision }) } } impl store::Transaction { pub fn new_revision>( title: cob::Title, description: impl ToString, doc: &Doc, parent: Option, repo: &R, signer: &G, ) -> Result { let mut tx = Transaction::default(); let (blob, bytes, signature) = doc.sign(signer).map_err(store::Error::Identity)?; // Store document blob in repository. let embed = Embed::::store("radicle.json", &bytes, repo.raw()).map_err(store::Error::Git)?; debug_assert_eq!(embed.content, Uri::from(blob)); // Make sure we pre-computed the correct OID for the blob. // Identity document. tx.embed([embed])?; // Revision metadata. tx.push(Action::Revision { title, description: description.to_string(), blob, parent, signature, })?; Ok(tx) } } pub struct IdentityMut<'a, 'b, Repo, Signer> { pub id: ObjectId, identity: Identity, store: store::Store<'a, Identity, Repo, WriteAs<'b, Signer>>, } impl fmt::Debug for IdentityMut<'_, '_, Repo, Signer> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("IdentityMut") .field("id", &self.id) .field("identity", &self.identity) .finish() } } impl IdentityMut<'_, '_, Repo, Signer> where Repo: WriteRepository + cob::Store, Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, Signer: crypto::signature::Signer, Signer: crypto::signature::Verifier, { /// Reload the identity data from storage. #[cfg(test)] pub fn reload(&mut self) -> Result<(), store::Error> { self.identity = self .store .get(&self.id)? .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?; Ok(()) } pub fn transaction(&mut self, message: &str, operations: F) -> Result where F: FnOnce(&mut Transaction, &Repo) -> Result<(), store::Error>, { let mut tx = Transaction::default(); operations(&mut tx, self.store.as_ref())?; let (doc, commit) = tx.commit(message, self.id, &mut self.store)?; self.identity = doc; Ok(commit) } /// Update the identity by proposing a new revision. /// /// If the signer is the only delegate, the revision is adopted automatically. /// /// # Errors /// /// [`SiblingAccepted`]: If the delegate has already accepted an active /// revision that shares the same parent as the provided `revision`. /// /// [`SiblingAccepted`]: ApplyError::SiblingAccepted pub fn update( &mut self, title: cob::Title, description: impl ToString, doc: &Doc, ) -> Result { #[allow(deprecated)] let did: Did = self.store.signer().verifying_key().into(); if let Some(revision_id) = self.has_accepted_active_sibling(&self.current, &did) { return Err(Error::Apply(ApplyError::SiblingAccepted { revision: revision_id, })); } let parent = Some(self.current); #[allow(deprecated)] let tx = { let signer = self.store.signer(); let repo = self.store.repo(); Transaction::new_revision(title, description, doc, parent, repo, signer)? }; let (doc, commit) = tx.commit("Propose revision", self.id, &mut self.store)?; self.identity = doc; Ok(commit) } /// Accept an active revision. /// /// # Errors /// /// [`SiblingAccepted`]: If the delegate has already accepted an active /// revision that shares the same parent as the provided `revision`. /// /// [`SiblingAccepted`]: ApplyError::SiblingAccepted pub fn accept(&mut self, revision: &RevisionId) -> Result { let id = *revision; let revision = self.revision(revision).ok_or(Error::NotFound(id))?; if let Some(parent_id) = revision.parent { #[allow(deprecated)] let did: Did = self.store.signer().verifying_key().into(); if let Some(revision_id) = self.has_accepted_active_sibling(&parent_id, &did) { return Err(Error::Apply(ApplyError::SiblingAccepted { revision: revision_id, })); } } #[allow(deprecated)] let signature = revision.sign(self.store.signer())?; self.transaction("Accept revision", |tx, _| tx.accept(id, signature)) } /// Reject an active revision. pub fn reject(&mut self, revision: RevisionId) -> Result { self.transaction("Reject revision", |tx, _| tx.reject(revision)) } /// Redact a revision. pub fn redact(&mut self, revision: RevisionId) -> Result { self.transaction("Redact revision", |tx, _| tx.redact(revision)) } /// Edit an active revision's title or description. pub fn edit( &mut self, revision: RevisionId, title: cob::Title, description: String, ) -> Result { self.transaction("Edit revision", |tx, _| { tx.edit(revision, title, description) }) } } impl Deref for IdentityMut<'_, '_, Repo, Signer> { type Target = Identity; fn deref(&self) -> &Self::Target { &self.identity } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod test { mod property; use qcheck_macros::quickcheck; use crate::cob::{self, Title}; use crate::crypto::PublicKey; use crate::identity::Visibility; use crate::identity::did::Did; use crate::identity::doc::PayloadId; use crate::node::device::Device; use crate::rad; use crate::storage::ReadStorage as _; use crate::storage::git::Storage; use crate::test::fixtures; use crate::test::setup::{Network, NodeWithRepo}; use super::*; #[quickcheck] fn prop_json_eq_str(pk: PublicKey, proj: RepoId, did: Did) { let json = serde_json::to_string(&pk).unwrap(); assert_eq!(format!("\"{pk}\""), json); let json = serde_json::to_string(&proj).unwrap(); assert_eq!(format!("\"{}\"", proj.urn()), json); let json = serde_json::to_string(&did).unwrap(); assert_eq!(format!("\"{did}\""), json); } #[test] fn test_identity_updates() { let NodeWithRepo { node, repo } = NodeWithRepo::default(); let bob = Device::mock(); let signer = &node.signer; let mut identity = Identity::load_mut(&*repo, signer).unwrap(); let mut doc = identity.doc().clone().edit(); let title = Title::new("Identity update").unwrap(); let description = ""; let r0 = identity.current; // The initial state is accepted. assert!(identity.current().is_accepted()); // Using an identical document to the current one fails. identity .update(title.clone(), description, &doc.clone().verified().unwrap()) .unwrap_err(); assert_eq!(identity.current, r0); // Change threshold to `2`, even though there's only one delegate. This should // fail as it makes the master branch immutable. doc.threshold = 2; assert!(doc.clone().verified().is_err()); // Let's add another delegate. doc.delegate(bob.public_key().into()); // The update should go through now. let r1 = identity .update(title.clone(), description, &doc.clone().verified().unwrap()) .unwrap(); assert!(identity.revision(&r1).unwrap().is_accepted()); assert_eq!(identity.current, r1); // With two delegates now, we need two signatures for any update to go through. // So this next update shouldn't be accepted as canonical until the second delegate // signs it. doc.visibility = Visibility::private([]); let r2 = identity .update(title.clone(), description, &doc.clone().verified().unwrap()) .unwrap(); // R1 is still the head. assert_eq!(identity.current, r1); assert_eq!(identity.revision(&r2).unwrap().state, State::Active); assert_eq!(repo.canonical_identity_head().unwrap(), r1); assert_eq!( repo.identity_doc().unwrap().visibility(), &Visibility::Public ); // Now let's add a signature on R2 from Bob. let mut bob_identity = Identity::load_mut(&*repo, &bob).unwrap(); bob_identity.accept(&r2).unwrap(); identity.reload().unwrap(); // R2 is now the head. assert_eq!(identity.current, r2); assert_eq!(identity.revision(&r2).unwrap().state, State::Accepted); assert_eq!(repo.canonical_identity_head().unwrap(), r2); assert_eq!( repo.canonical_identity_doc().unwrap().visibility(), &Visibility::private([]) ); } #[test] fn test_identity_update_rejected() { let NodeWithRepo { node, repo } = NodeWithRepo::default(); let bob = Device::mock(); let eve = Device::mock(); let signer = &node.signer; let mut identity = Identity::load_mut(&*repo, signer).unwrap(); let mut doc = identity.doc().clone().edit(); let description = ""; // Let's add another delegate. doc.delegate(bob.public_key().into()); let r1 = identity .update( cob::Title::new("Identity update").unwrap(), description, &doc.clone().verified().unwrap(), ) .unwrap(); assert_eq!(identity.current, r1); doc.visibility = Visibility::private([]); let r2 = identity .update( cob::Title::new("Make private").unwrap(), description, &doc.clone().verified().unwrap(), ) .unwrap(); let mut bob_identity = Identity::load_mut(&*repo, &bob).unwrap(); // 1/2 rejected means that we can never reach the required 2/2 votes. bob_identity.reject(r2).unwrap(); let r2 = bob_identity.revision(&r2).unwrap(); assert_eq!(r2.state, State::Rejected(RejectedBy::Vote)); // Reload so Alice sees r2 is rejected (no longer Active). // This allows her to propose a new sibling. identity.reload().unwrap(); // Now let's add another delegate. doc.delegate(eve.public_key().into()); let r3 = identity .update( cob::Title::new("Add Eve").unwrap(), description, &doc.clone().verified().unwrap(), ) .unwrap(); bob_identity.reload().unwrap(); let _ = bob_identity.accept(&r3).unwrap(); identity.reload().unwrap(); assert_eq!(identity.current, r3); doc.visibility = Visibility::Public; let r3 = identity .update( cob::Title::new("Make public").unwrap(), description, &doc.verified().unwrap(), ) .unwrap(); // 1/3 rejected means that we can still reach the 2/3 required votes. bob_identity.reject(r3).unwrap(); let r3 = identity.revision(&r3).unwrap().clone(); assert_eq!(r3.state, State::Active); // Still active. let mut eve_identity = Identity::load_mut(&*repo, &eve).unwrap(); // 2/3 rejected means that we can no longer reach the 2/3 required votes. eve_identity.reject(r3.id).unwrap(); let r3 = eve_identity.revision(&r3.id).unwrap(); assert_eq!(r3.state, State::Rejected(RejectedBy::Vote)); } #[test] fn test_identity_updates_concurrent() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "", &alice_doc.clone().verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); let bob_identity = Identity::load(&*bob.repo).unwrap(); let bob_doc = bob_identity.doc().clone(); assert!(bob_doc.is_delegate(&bob.signer.public_key().into())); // Alice changes the document without making Bob aware. alice_doc.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("Change visibility").unwrap(), "", &alice_doc.clone().clone().verified().unwrap(), ) .unwrap(); let bob_identity_mut = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); assert_eq!(*bob_identity_mut, bob_identity); let mut bob_identity = bob_identity_mut; // Bob makes the same change without knowing Alice already did. let b1 = bob_identity .update( cob::Title::new("Make private").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); // Bob gets Alice's data. bob.repo.fetch(alice); bob_identity.reload().unwrap(); assert_eq!(bob_identity.current, a1); // Alice gets Bob's data. // There's not enough votes for either of these proposals to pass. alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!(alice_identity.current, a1); assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Active); assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Active); // Bob must redact his revision, before he accepts Alice's proposal. bob_identity.redact(b1).unwrap(); bob_identity.accept(&a2).unwrap(); assert_eq!(bob_identity.current, a2); assert_eq!(bob_identity.revision(&a1).unwrap().state, State::Accepted); assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Accepted); assert_eq!( bob_identity.revision(&b1).unwrap().state, State::Redacted(RedactedBy::Author) ); } #[test] fn test_identity_redact_revision() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "Eh.", &alice_doc.clone().clone().verified().unwrap(), ) .unwrap(); alice_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let a2 = alice_identity .update( cob::Title::new("Change visibility").unwrap(), "Eh.", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); let a3 = cob::stable::with_advanced_timestamp(|| alice_identity.redact(a2).unwrap()); assert!(alice_identity.revision(&a1).is_some()); assert_eq!(alice_identity.timeline, vec![a0, a1, a2, a3]); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let b1 = cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2).unwrap()); assert_eq!(bob_identity.timeline, vec![a0, a1, a2, b1]); assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Accepted); bob.repo.fetch(alice); bob_identity.reload().unwrap(); assert_eq!(bob_identity.timeline, vec![a0, a1, a2, a3, b1]); assert_eq!( bob_identity.revision(&a2).unwrap().state, State::Redacted(RedactedBy::Author) ); assert_eq!(bob_identity.current, a1); } #[test] fn redact_parent_cascades() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; // Alice adds Bob. let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); // Alice proposes A₂. Since there are 2 delegates now, it stays Active. let mut alice_doc2 = alice_identity.doc().clone().edit(); alice_doc2.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("A₂").unwrap(), "", &alice_doc2.verified().unwrap(), ) .unwrap(); // Bob fetches and proposes B₁ as a child of A₂. bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut bob_doc = bob_identity.doc().clone().edit(); bob_doc.visibility = Visibility::private([alice.signer.public_key().into()]); // We use a manual transaction to force B₁ to be a child of the Active A₂, // rather than the Accepted A₁. let b1 = bob_identity .transaction("B₁", |tx, repo| { *tx = Transaction::new_revision( cob::Title::new("B₁").unwrap(), "", &bob_doc.verified().unwrap(), Some(a2), repo, &bob.signer, )?; Ok(()) }) .unwrap(); // Alice redacts A₂. alice_identity.redact(a2).unwrap(); // Bob fetches Alice's redaction. bob.repo.fetch(alice); bob_identity.reload().unwrap(); // b1 (Propose "B₁") 1/2 (RedactedBy::Parent due to parent A₂ being redacted) // | // a2 (Propose "A₂") 1/2 (RedactedBy::Author by Alice) // | // a1 (Add Bob) 1/1 (Accepted) // | // a0 assert_eq!( bob_identity.revision(&a2).unwrap().state, State::Redacted(RedactedBy::Author) ); assert_eq!( bob_identity.revision(&b1).unwrap().state, State::Redacted(RedactedBy::Parent) ); } /// When a sibling revision is accepted, competing siblings from other /// delegates are rejected with `Rejected(Sibling)`. #[test] fn accepted_sibling_causes_rejection() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("Add Bob and Eve").unwrap(), "Eh#!", &alice_doc.clone().verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); // Bob proposes b1. let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut bob_doc = bob_identity.doc().clone().edit(); bob_doc.visibility = Visibility::private([]); let b1 = cob::stable::with_advanced_timestamp(|| { bob_identity .update( cob::Title::new("Make private").unwrap(), "", &bob_doc.verified().unwrap(), ) .unwrap() }); // Eve proposes e1 (a competing sibling from a different delegate). let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e1 = cob::stable::with_advanced_timestamp(|| { eve_identity .update( cob::Title::new("Change visibility").unwrap(), "", &eve_doc.verified().unwrap(), ) .unwrap() }); // Eve fetches Bob's proposal. She redacts her own proposal e1 // before accepting Bob's b1 (sibling-accept invariant). eve.repo.fetch(bob); eve_identity.reload().unwrap(); cob::stable::with_advanced_timestamp(|| eve_identity.redact(e1).unwrap()); cob::stable::with_advanced_timestamp(|| eve_identity.accept(&b1).unwrap()); // b1 is accepted (Bob + Eve = 2/3), becomes current. assert_eq!(eve_identity.current, b1); // e1 was redacted by Eve. assert_eq!( eve_identity.revision(&e1).unwrap().state, State::Redacted(RedactedBy::Author) ); } #[test] fn remove_delegate_concurrent() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); assert_eq!(alice_doc.delegates.len(), 3); let a0 = alice_identity.root; let a1 = alice_identity // Change description to change traversal order. .update( cob::Title::new("Add Bob and Eve").unwrap(), "Eh#!", &alice_doc.clone().verified().unwrap(), ) .unwrap(); alice_doc.rescind(&eve.signer.public_key().into()).unwrap(); assert_eq!(alice_doc.delegates.len(), 2); let a2 = alice_identity .update( cob::Title::new("Remove Eve").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(eve); bob.repo.fetch(alice); eve.repo.fetch(bob); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let b1 = cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2).unwrap()); assert_eq!(bob_identity.current, a2); let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e1 = cob::stable::with_advanced_timestamp(|| { eve_identity .update( cob::Title::new("Change visibility").unwrap(), "", &eve_doc.verified().unwrap(), ) .unwrap() }); // Eve's revision is active. assert_eq!(eve_identity.timeline, vec![a0, a1, a2, e1]); assert!(eve_identity.revision(&e1).unwrap().is_active()); // b1 (Accept "Remove Eve") 2/2 // | e1 (Change visibility) // | / // a2 (Propose "Remove Eve") 1/2 // | // a1 (Add Bob and Eve) // | // a0 eve.repo.fetch(bob); eve_identity.reload().unwrap(); // Now that Eve reloaded, since Bob's vote to remove Eve went through first (b1 < e1), // her revision is no longer valid. assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1]); assert_eq!( eve_identity.revision(&e1).unwrap().state, State::Rejected(RejectedBy::Sibling(a2)) ); assert!(!eve_identity.is_delegate(&eve.signer.public_key().into())); } #[test] fn reject_concurrent() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity .update( cob::Title::new("Add Bob and Eve").unwrap(), "Eh!#", &alice_doc.clone().verified().unwrap(), ) .unwrap(); alice_doc.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("Change visibility").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(eve); bob.repo.fetch(alice); eve.repo.fetch(bob); // Bob accepts alice's revision. let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let b1 = cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2).unwrap()); // Eve rejects the revision, not knowing. let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); let e1 = cob::stable::with_advanced_timestamp(|| eve_identity.reject(a2).unwrap()); assert!(eve_identity.revision(&a2).unwrap().is_active()); // Then she submits a new revision. let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e2 = eve_identity .update( cob::Title::new("Change visibility").unwrap(), "", &eve_doc.verified().unwrap(), ) .unwrap(); let eve_revision = eve_identity.revision(&e2).unwrap(); assert_eq!(eve_revision.state, State::Active); assert_eq!(eve_revision.parent, Some(a1)); // e2 (Propose "Change visibility") 1/3 // | // e1 (Reject "Change visibility") 1/3 // b1 | (Accept "Change visibility") 2/3 // | / // a2 (Propose "Change visibility") 1/3 // | // a1 (Add Bob and Eve) 1/1 // | // a0 // Though the rules are that you cannot reject an already accepted revision, // since this update was done concurrently there was no way of knowing. Therefore, // an error shouldn't be returned. We simply ignore the rejection. eve.repo.fetch(bob); eve_identity.reload().unwrap(); assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1, e2]); // Her revision is there, but rejected, since a sibling revision was already accepted. let e2 = eve_identity.revision(&e2).unwrap(); assert_eq!(e2.state, State::Rejected(RejectedBy::Sibling(a2))); assert!(eve_identity.revision(&a2).unwrap().is_accepted()); } #[test] fn test_identity_updates_concurrent_outdated() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice.repo.fetch(bob); alice.repo.fetch(eve); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity .update( cob::Title::new("Add Bob and Eve").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut bob_doc = bob_identity.doc().clone().edit(); assert!(bob_doc.is_delegate(&bob.signer.public_key().into())); // a2 e1 // | / // b1 // | // a1 // | // a0 // Bob and Alice change the document visibility. Eve is not aware. bob_doc.visibility = Visibility::private([]); let b1 = bob_identity .update( cob::Title::new("Change visibility #1").unwrap(), "", &bob_doc.verified().unwrap(), ) .unwrap(); alice.repo.fetch(bob); eve.repo.fetch(bob); // In the meantime, Eve does the same thing on her side. let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([]); let e1 = eve_identity .update( cob::Title::new("Change visibility #2").unwrap(), "Woops", &eve_doc.verified().unwrap(), ) .unwrap(); assert_eq!(eve_identity.revisions().count(), 4); assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Active); alice_identity.reload().unwrap(); let a2 = cob::stable::with_advanced_timestamp(|| alice_identity.accept(&b1).unwrap()); eve.repo.fetch(alice); eve_identity.reload().unwrap(); assert_eq!(eve_identity.timeline, vec![a0, a1, b1, e1, a2]); assert_eq!( eve_identity.revision(&e1).unwrap().state, State::Rejected(RejectedBy::Sibling(b1)) ); } #[test] fn cascading_rejections() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("Add Bob and Eve").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); // Bob proposes B1 (child of A1, the current revision). let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut bob_doc = bob_identity.doc().clone().edit(); bob_doc.visibility = Visibility::private([]); let b1 = bob_identity .update( cob::Title::new("B1").unwrap(), "", &bob_doc.clone().verified().unwrap(), ) .unwrap(); // Bob proposes B2 as a **child of B1** (not A1). // We use a manual transaction to set B1 as the parent, since `update()` // always uses `self.current` (= A1, because B1 hasn't been accepted). let mut bob_doc2 = bob_doc.clone(); bob_doc2.visibility = Visibility::private([bob.signer.public_key().into()]); let b2 = bob_identity .transaction("B2", |tx, repo| { *tx = Transaction::new_revision( cob::Title::new("B2").unwrap(), "", &bob_doc2.verified().unwrap(), Some(b1), repo, &bob.signer, )?; Ok(()) }) .unwrap(); // Eve proposes E1 (child of A1, sibling of B1). let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e1 = eve_identity .update( cob::Title::new("E1").unwrap(), "", &eve_doc.verified().unwrap(), ) .unwrap(); // Alice accepts E1 → E1 reaches 2/3 → adopted. alice.repo.fetch(eve); alice_identity.reload().unwrap(); alice_identity.accept(&e1).unwrap(); // Eve syncs with everyone. eve.repo.fetch(bob); eve.repo.fetch(alice); eve_identity.reload().unwrap(); // b2 (Propose "B2") [Rejected(Parent) — cascaded from B1] // | // b1 (Propose "B1") [Rejected(Sibling(E1)) — sibling E1 accepted] // e1 | (Propose "E1") [Accepted, 2/3] // | / // a1 (Add Bob and Eve) // | // a0 assert_eq!(eve_identity.current, e1); assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Accepted); // B1 is rejected because its sibling E1 was accepted. assert_eq!( eve_identity.revision(&b1).unwrap().state, State::Rejected(RejectedBy::Sibling(e1)) ); // B2 is rejected because its **parent** B1 was rejected (cascading). assert_eq!( eve_identity.revision(&b2).unwrap().state, State::Rejected(RejectedBy::Parent) ); // Verify convergence across all nodes. alice.repo.fetch(bob); bob.repo.fetch(alice); bob.repo.fetch(eve); alice_identity.reload().unwrap(); bob_identity.reload().unwrap(); assert_eq!(alice_identity.current, e1); assert_eq!( alice_identity.revision(&b1).unwrap().state, State::Rejected(RejectedBy::Sibling(e1)) ); assert_eq!( alice_identity.revision(&b2).unwrap().state, State::Rejected(RejectedBy::Parent) ); assert_eq!(bob_identity.current, e1); assert_eq!( bob_identity.revision(&b1).unwrap().state, State::Rejected(RejectedBy::Sibling(e1)) ); assert_eq!( bob_identity.revision(&b2).unwrap().state, State::Rejected(RejectedBy::Parent) ); } #[test] fn terminal_states_concurrent() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let a1 = alice_identity .update( cob::Title::new("Add Bob and Eve").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); bob_identity.accept(&a1).unwrap(); eve_identity.accept(&a1).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!(alice_identity.revision(&a1).unwrap().state, State::Accepted); alice.repo.fetch(eve); alice_identity.reload().unwrap(); assert_eq!(alice_identity.revision(&a1).unwrap().state, State::Accepted); let mut alice_doc2 = alice_identity.doc().clone().edit(); alice_doc2.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("A2").unwrap(), "", &alice_doc2.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); bob_identity.reload().unwrap(); eve_identity.reload().unwrap(); bob_identity.reject(a2).unwrap(); eve_identity.reject(a2).unwrap(); alice.repo.fetch(bob); alice.repo.fetch(eve); alice_identity.reload().unwrap(); assert_eq!( alice_identity.revision(&a2).unwrap().state, State::Rejected(RejectedBy::Vote) ); // a2 (Propose "A2") 1/3 (Rejected by Bob and Eve) // | // a1 (Add Bob and Eve) 3/3 (Accepted by Alice, Bob, Eve) // | // a0 // Alice tries to accept the rejected revision alice_identity.accept(&a2).unwrap(); assert_eq!( alice_identity.revision(&a2).unwrap().state, State::Rejected(RejectedBy::Vote) ); } #[test] fn test_identity_cannot_redact_terminal_states() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); bob_identity.accept(&a1).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); let mut alice_doc2 = alice_identity.doc().clone().edit(); alice_doc2.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("A2").unwrap(), "", &alice_doc2.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); bob_identity.reload().unwrap(); bob_identity.accept(&a2).unwrap(); alice_identity.redact(a2).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!( alice_identity.revision(&a2).unwrap().state, State::Redacted(RedactedBy::Author) ); let mut alice_doc3 = alice_identity.doc().clone().edit(); alice_doc3.visibility = Visibility::private([alice.signer.public_key().into()]); let a3 = alice_identity .update( cob::Title::new("A3").unwrap(), "", &alice_doc3.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); bob_identity.reload().unwrap(); bob_identity.accept(&a3).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!(alice_identity.revision(&a3).unwrap().state, State::Accepted); alice_identity.redact(a3).unwrap(); assert_eq!(alice_identity.revision(&a3).unwrap().state, State::Accepted); let mut alice_doc4 = alice_identity.doc().clone().edit(); alice_doc4.visibility = Visibility::private([]); let a4 = alice_identity .update( cob::Title::new("A4").unwrap(), "", &alice_doc4.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); bob_identity.reload().unwrap(); bob_identity.reject(a4).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!( alice_identity.revision(&a4).unwrap().state, State::Rejected(RejectedBy::Vote) ); // a4 (Propose "A4") 1/2 (Rejected by Bob) -> Redact attempt ignored // | // a3 (Propose "A3") 2/2 (Accepted by Alice, Bob) -> Redact attempt ignored // | \ // | a2 (Propose "A2") 1/2 (Redacted by Alice concurrently with Bob's Accept) // | / // a1 (Add Bob) 2/2 (Accepted by Alice, Bob) // | // a0 alice_identity.redact(a4).unwrap(); assert_eq!( alice_identity.revision(&a4).unwrap().state, State::Rejected(RejectedBy::Vote) ); } /// A previously accepted revision that is no longer the current revision /// cannot be redacted. It was part of the canonical history and its state /// should be immutable. #[test] fn cannot_redact_previously_accepted_revision() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let a1 = alice_identity .update( cob::Title::new("A₁").unwrap(), "Add Bob", &alice_doc.verified().unwrap(), ) .unwrap(); // A₁ is accepted by Bob, thus reaches 2/2 votes, is accepted // and becomes the current revision. bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); bob_identity.accept(&a1).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!(alice_identity.current, a1); assert_eq!(alice_identity.revision(&a1).unwrap().state, State::Accepted); // A₂ is proposed and accepted, is acceptedy by Bob, thus reaches 2/2 votes, // is accepted, and becomes the current revision, superseding A₁. let mut alice_doc2 = alice_identity.doc().clone().edit(); alice_doc2.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("A₂").unwrap(), "", &alice_doc2.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); bob_identity.reload().unwrap(); bob_identity.accept(&a2).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!(alice_identity.current, a2); // A₁ is now previously accepted but not current anymore. // // A₂ [Accepted, current] // | // A₁ [Accepted, previously current] // | // A₀ assert_eq!(alice_identity.revision(&a1).unwrap().state, State::Accepted); assert_ne!(alice_identity.current, a1); // Attempting to redact A₁ should be silently ignored. alice_identity.redact(a1).unwrap(); assert_eq!(alice_identity.revision(&a1).unwrap().state, State::Accepted); assert_eq!(alice_identity.current, a2); } /// When Alice redacts a revision and Bob concurrently accepts it, /// the outcome depends on CRDT evaluation order (timestamp-based). /// Regardless of which node syncs first, both must converge. #[test] fn concurrent_redact_and_accept_converge() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; // Setup: Alice adds Bob as delegate. A₁ is auto-accepted (1/1 votes). let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let a1 = alice_identity .update( cob::Title::new("A₁").unwrap(), "Add Bob", &alice_doc.verified().unwrap(), ) .unwrap(); assert_eq!(alice_identity.current, a1); // Alice proposes A₂. With 2 delegates, needs 2/2 votes. Stays Active. let mut alice_doc2 = alice_identity.doc().clone().edit(); alice_doc2.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("A₂").unwrap(), "", &alice_doc2.verified().unwrap(), ) .unwrap(); // Bob fetches and sees A₂. bob.repo.fetch(alice); // Concurrent actions (no sync between them): // Alice redacts A₂ (timestamp T). let _a3 = cob::stable::with_advanced_timestamp(|| alice_identity.redact(a2).unwrap()); // Bob accepts A₂ (timestamp T+1, later than Alice's redaction). let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let _b1 = cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2).unwrap()); // Before sync: each node has a different local view. // Alice: A₂ is Redacted(Author), current = A₁. assert_eq!( alice_identity.revision(&a2).unwrap().state, State::Redacted(RedactedBy::Author) ); assert_eq!(alice_identity.current, a1); // Bob: A₂ is Accepted (2/2 votes locally), current = A₂. assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Accepted); assert_eq!(bob_identity.current, a2); // Now sync: Bob fetches Alice, Alice fetches Bob. bob.repo.fetch(alice); bob_identity.reload().unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); // After sync: both must agree. // The redaction (earlier timestamp) is processed before the accept. // When the accept is processed, A₂ is already Redacted → accept is skipped. assert_eq!( alice_identity.revision(&a2).unwrap().state, bob_identity.revision(&a2).unwrap().state, "Alice and Bob must agree on A₂'s state" ); assert_eq!( alice_identity.current, bob_identity.current, "Alice and Bob must agree on the current revision" ); // The redaction wins (earlier timestamp), so current reverts to A₁. assert_eq!( alice_identity.revision(&a2).unwrap().state, State::Redacted(RedactedBy::Author) ); assert_eq!(alice_identity.current, a1); } /// Companion to `concurrent_redact_and_accept_converge`: here Bob /// acts first (earlier timestamp) and Alice redacts second (later /// timestamp). The accept is processed first and wins. #[test] fn concurrent_accept_before_redact_converge() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("A₁").unwrap(), "Add Bob", &alice_doc.verified().unwrap(), ) .unwrap(); let mut alice_doc2 = alice_identity.doc().clone().edit(); alice_doc2.visibility = Visibility::private([]); let a2 = alice_identity .update( cob::Title::new("A₂").unwrap(), "", &alice_doc2.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); // Bob accepts FIRST (earlier timestamp). let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let _b1 = cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2).unwrap()); // Alice redacts SECOND (later timestamp). let _a3 = cob::stable::with_advanced_timestamp(|| alice_identity.redact(a2).unwrap()); // Sync both directions. bob.repo.fetch(alice); bob_identity.reload().unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); // Both must agree. assert_eq!( alice_identity.revision(&a2).unwrap().state, bob_identity.revision(&a2).unwrap().state, "Alice and Bob must agree on A₂'s state" ); assert_eq!( alice_identity.current, bob_identity.current, "Alice and Bob must agree on the current revision" ); // The accept (earlier timestamp) is processed first. // A₂ reaches 2/2 votes → Accepted. Current becomes A₂. // Then the redaction is processed: A₂ is Accepted (not Active) → skipped. assert_eq!(alice_identity.revision(&a2).unwrap().state, State::Accepted); assert_eq!(alice_identity.current, a2); } #[test] fn test_valid_identity() { let tempdir = tempfile::tempdir().unwrap(); let mut rng = fastrand::Rng::new(); let alice = Device::mock_rng(&mut rng); let bob = Device::mock_rng(&mut rng); let eve = Device::mock_rng(&mut rng); let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap(); let (id, _, _, _) = fixtures::project(tempdir.path().join("copy"), &storage, &alice).unwrap(); // Bob and Eve fork the project from Alice. rad::fork_remote(id, alice.public_key(), &bob, &storage).unwrap(); rad::fork_remote(id, alice.public_key(), &eve, &storage).unwrap(); let repo = storage.repository(id).unwrap(); let mut identity = Identity::load_mut(&repo, &alice).unwrap(); let doc = identity.doc().clone(); let prj = doc.project().unwrap(); let mut doc = doc.edit(); // Make a change to the description and sign it. let desc = prj.description().to_owned() + "!"; let prj = prj.update(None, desc, None).unwrap(); doc.payload.insert(PayloadId::project(), prj.clone().into()); identity .update( cob::Title::new("Update description").unwrap(), "", &doc.clone().verified().unwrap(), ) .unwrap(); // Add Bob as a delegate, and sign it. doc.delegate(bob.public_key().into()); doc.threshold = 2; identity .update( cob::Title::new("Add bob").unwrap(), "", &doc.clone().verified().unwrap(), ) .unwrap(); // Add Eve as a delegate. doc.delegate(eve.public_key().into()); // Update with both Bob and Alice's signature. let revision = identity .update( cob::Title::new("Add eve").unwrap(), "", &doc.clone().verified().unwrap(), ) .unwrap(); let mut bob_identity = Identity::load_mut(&repo, &bob).unwrap(); bob_identity.accept(&revision).unwrap(); // Update description again with signatures by Eve and Bob. let desc = prj.description().to_owned() + "?"; let prj = prj.update(None, desc, None).unwrap(); doc.payload.insert(PayloadId::project(), prj.into()); let revision = bob_identity .update( cob::Title::new("Update description again").unwrap(), "Bob's repository", &doc.verified().unwrap(), ) .unwrap(); let mut eve_identity = Identity::load_mut(&repo, &eve).unwrap(); eve_identity.accept(&revision).unwrap(); let identity: Identity = Identity::load(&repo).unwrap(); let root = repo.identity_root().unwrap(); let doc = repo.identity_doc_at(revision).unwrap(); assert_eq!(identity.signatures().count(), 2); assert_eq!(identity.revisions().count(), 5); assert_eq!(RepoId::from(identity.root().blob), id); assert_eq!(identity.root().id, root); assert_eq!(identity.current().blob, doc.blob); assert_eq!(identity.current().description.as_str(), "Bob's repository"); assert_eq!(identity.head(), revision); assert_eq!(identity.doc(), &*doc); assert_eq!( identity.doc().project().unwrap().description(), "Acme's repository!?" ); assert_eq!(doc.project().unwrap().description(), "Acme's repository!?"); } #[test] fn evaluates_queued_children() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; // Setup. Alice, Bob, and Eve are delegates. Majority required is 2. let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity .update( cob::Title::new("Add Bob and Eve").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); bob_identity.accept(&a0).unwrap(); eve_identity.accept(&a0).unwrap(); alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!(alice_identity.current, a0); // Alice proposes A1 and B1 let mut doc_a1 = alice_identity.doc().clone().edit(); doc_a1.visibility = Visibility::private([]); let a1 = alice_identity .update( cob::Title::new("A1").unwrap(), "", &doc_a1.clone().verified().unwrap(), ) .unwrap(); let mut doc_b1 = doc_a1.clone(); doc_b1.visibility = Visibility::private([bob.signer.public_key().into()]); let b1 = alice_identity .transaction("B1", |tx, repo| { *tx = Transaction::new_revision( cob::Title::new("B1").unwrap(), "", &doc_b1.verified().unwrap(), Some(a1), repo, &alice.signer, )?; Ok(()) }) .unwrap(); // Bob fetches and accepts B1. // B1 now has 2 votes (Alice + Bob). The majority required is 2. // However, B1's parent (A1) is not yet accepted. bob.repo.fetch(alice); bob_identity.reload().unwrap(); bob_identity.accept(&b1).unwrap(); // B1 is queued and not yet accepted assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Active); // Bob accepts A1. // A1 reaches 2 votes and is Accepted. // B1 already has 2 votes, so it should be // automatically accepted. // // b1 [Accepted, 2/2 votes] // | // a1 [Accepted, 2/2 votes] // | // a0 [Accepted] bob_identity.accept(&a1).unwrap(); assert_eq!(bob_identity.revision(&a1).unwrap().state, State::Accepted); assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Accepted); assert_eq!(bob_identity.current, b1); } /// When a revision is adopted that changes the delegate set, the majority /// threshold may change. Queued children should be re-evaluated under the /// new quorum rules. /// /// This test exercises the case where a delegate is removed, lowering /// the majority from 3 (for 4 delegates) to 2 (for 3 delegates), which /// enables a queued child to be automatically adopted. #[test] fn evaluates_queued_children_with_new_delegate() { use crate::crypto::test::signer::MockSigner; use crate::test::setup::{Node, NodeRepo}; use tempfile::tempdir; let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; // Create Dave as a 4th participant. let mut dave_node = Node::new(tempdir().unwrap(), MockSigner::from_seed([!3; 32]), "dave"); dave_node.clone(network.rid, alice); let dave_repo = NodeRepo { repo: dave_node.storage.repository(network.rid).unwrap(), checkout: None, }; // A1: Alice adds Bob, Eve, and Dave as delegates. // Alice is the sole delegate, so this is auto-accepted. // Result: 4 delegates {Alice, Bob, Eve, Dave}, majority = 3. let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); alice_doc.delegate(dave_node.signer.public_key().into()); let a1 = alice_identity .update( cob::Title::new("Add Bob, Eve, and Dave").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); assert_eq!(alice_identity.current, a1); assert_eq!(alice_identity.doc().delegates().len(), 4); // Sync everyone. bob.repo.fetch(alice); eve.repo.fetch(alice); dave_repo.fetch(alice); // A2: Alice proposes removing Dave. // Under A1's rules (4 delegates), majority = 3. Alice has 1 vote. Active. let mut doc_a2 = alice_identity.doc().clone().edit(); doc_a2 .rescind(&dave_node.signer.public_key().into()) .unwrap(); let a2 = alice_identity .update( cob::Title::new("Remove Dave").unwrap(), "", &doc_a2.clone().verified().unwrap(), ) .unwrap(); assert_eq!(alice_identity.revision(&a2).unwrap().state, State::Active); // B1: Alice proposes a child of A2 (changes visibility). // B1's parent is A2 (Active, not current), so we use a manual transaction. let mut doc_b1 = doc_a2.clone(); doc_b1.visibility = Visibility::private([]); let b1 = alice_identity .transaction("B1", |tx, repo| { *tx = Transaction::new_revision( cob::Title::new("B1: Change visibility").unwrap(), "", &doc_b1.verified().unwrap(), Some(a2), repo, &alice.signer, )?; Ok(()) }) .unwrap(); // Bob fetches Alice's changes and accepts B1. // B1 now has 2 votes (Alice + Bob). Both are delegates in A2's doc. // But B1's parent A2 is not yet current, so B1 stays Active. bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); bob_identity.accept(&b1).unwrap(); assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Active); // Bob accepts A2. A2 now has 2/4 votes. Still needs 3. bob_identity.accept(&a2).unwrap(); assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Active); // Eve fetches from Alice and Bob, then accepts A2. // A2 reaches 3/4 votes (Alice + Bob + Eve) → adopted! // A2's doc has 3 delegates {Alice, Bob, Eve}, majority = 2. // Re-evaluate children: B1 has 2 votes (Alice + Bob), 2 >= 2 → adopted! // // b1 [Accepted, 2 votes (Alice + Bob), majority 2 under A2's doc] // | // a2 [Accepted, 3 votes (Alice + Bob + Eve), majority 3 under A1's doc] // | // a1 [Accepted, 4 delegates] // | // a0 eve.repo.fetch(alice); eve.repo.fetch(bob); let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); eve_identity.accept(&a2).unwrap(); assert_eq!(eve_identity.revision(&a2).unwrap().state, State::Accepted); assert_eq!(eve_identity.doc().delegates().len(), 3); assert_eq!(eve_identity.revision(&b1).unwrap().state, State::Accepted); assert_eq!(eve_identity.current, b1); } /// Demonstrates that authorization to vote on a revision is strictly governed /// by its parent, not by the currently accepted identity document. #[test] fn authorization_based_on_parent_not_current() { use crate::crypto::test::signer::MockSigner; use crate::test::setup::{Node, NodeRepo}; use tempfile::tempdir; let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; // Create Dave as a 4th participant. let mut dave_node = Node::new(tempdir().unwrap(), MockSigner::from_seed([!3; 32]), "dave"); dave_node.clone(network.rid, alice); let dave_repo = NodeRepo { repo: dave_node.storage.repository(network.rid).unwrap(), checkout: None, }; // Revision A₁ lists 4 delegates: Alice, Bob, Eve, and Dave. // Since alice is the only delegate initially, A₁ is immediately accepted // and becomes the current revision. let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); alice_doc.delegate(dave_node.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("A₁").unwrap(), "Add Bob, Eve, and Dave", &alice_doc.verified().unwrap(), ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); dave_repo.fetch(alice); // Revision A₂ lists 3 delegates: Alice, Bob, and Eve. // Dave is removed in this proposal. let mut doc_a2 = alice_identity.doc().clone().edit(); doc_a2 .rescind(&dave_node.signer.public_key().into()) .unwrap(); let a2 = alice_identity .update( cob::Title::new("A₂").unwrap(), "Remove Dave", &doc_a2.clone().verified().unwrap(), ) .unwrap(); // Revision A₃ is a child of A₂. // Note that at this point, A₁ is still the currently accepted revision, // meaning Dave is a delegate according to the currently accepted revision. let mut doc_a3 = doc_a2.clone(); doc_a3.visibility = Visibility::private([]); let a3 = alice_identity .transaction("A₃", |tx, repo| { *tx = Transaction::new_revision( cob::Title::new("A₃").unwrap(), "Set visibility to private", &doc_a3.verified().unwrap(), Some(a2), repo, &alice.signer, )?; Ok(()) }) .unwrap(); // Dave fetches and attempts to accept A₃. // Even though Dave is a delegate in the currently accepted revision (A₁), // authorization is governed by the parent of A₃, which is A₂. // Because A₂ removed Dave, this action must error. dave_repo.fetch(alice); let mut dave_identity = Identity::load_mut(&*dave_repo, &dave_node.signer).unwrap(); let err = dave_identity.accept(&a3).unwrap_err(); let is_unauthorized = std::iter::successors::<&dyn std::error::Error, _>(Some(&err), |err| err.source()).any( |source| { matches!( source.downcast_ref::(), Some(ApplyError::NonDelegateUnauthorized { .. }) ) }, ); assert!( is_unauthorized, "Dave should be unauthorized because he was removed in the parent (A₂). Actual error: {err:?}" ); } #[test] fn has_accepted_active_sibling_returns_true_when_sibling_accepted() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; // Setup: Alice adds Bob as delegate. let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); // Alice proposes a revision (child of current). Alice has an implicit accept. let mut doc1 = alice_identity.doc().clone().edit(); doc1.visibility = Visibility::private([]); let child = alice_identity .update( cob::Title::new("Child 1").unwrap(), "", &doc1.verified().unwrap(), ) .unwrap(); let alice_did: Did = alice.signer.public_key().into(); assert_eq!( alice_identity.has_accepted_active_sibling(&alice_identity.current, &alice_did), Some(child) ); let bob_did: Did = bob.signer.public_key().into(); assert_eq!( alice_identity.has_accepted_active_sibling(&alice_identity.current, &bob_did), None ); } /// Old histories may contain a delegate explicitly accepting two /// siblings. The evaluation layer must handle this gracefully by /// silently skipping the second accept — the vote must not be recorded. #[test] fn evaluation_skips_sibling_accept_in_old_history() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); // Bob proposes child_a (gets implicit accept). bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut bob_doc = bob_identity.doc().clone().edit(); bob_doc.visibility = Visibility::private([]); let child_a = bob_identity .update( cob::Title::new("Child A").unwrap(), "", &bob_doc.verified().unwrap(), ) .unwrap(); // Alice proposes child_b (sibling, gets implicit accept). let mut alice_doc2 = alice_identity.doc().clone().edit(); alice_doc2.visibility = Visibility::private([alice.signer.public_key().into()]); let _child_b = alice_identity .update( cob::Title::new("Child B").unwrap(), "", &alice_doc2.verified().unwrap(), ) .unwrap(); // Alice fetches Bob's child_a and explicitly accepts it // (simulating old history — she already has an accept on child_b). alice.repo.fetch(bob); alice_identity.reload().unwrap(); // Use transaction to bypass the API guard (simulating old history). let sig = alice_identity .revision(&child_a) .unwrap() .sign(&alice.signer) .unwrap(); alice_identity .transaction("Accept child_a", |tx, _| tx.accept(child_a, sig)) .unwrap(); let alice_did: Did = alice.signer.public_key().into(); // Alice's accept on child_a should be skipped because she already // has an accept on the Active sibling child_b. assert!( !alice_identity .revision(&child_a) .unwrap() .accepted() .any(|did| did == alice_did), "Alice's accept on child_a should have been skipped (she already accepted sibling child_b)" ); } /// When replaying an old history where a delegate created two sibling /// revisions, the second revision's implicit author accept is stripped. #[test] fn evaluation_strips_author_accept_for_sibling_creation() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); // Alice proposes first child (gets implicit accept). let mut doc1 = alice_identity.doc().clone().edit(); doc1.visibility = Visibility::private([]); let child1 = alice_identity .update( cob::Title::new("Child 1").unwrap(), "", &doc1.clone().verified().unwrap(), ) .unwrap(); // Alice proposes second child via transaction (simulating old history). let mut doc2 = doc1.clone(); doc2.visibility = Visibility::private([alice.signer.public_key().into()]); let current = alice_identity.current; let child2 = alice_identity .transaction("Child 2", |tx, repo| { *tx = Transaction::new_revision( cob::Title::new("Child 2").unwrap(), "", &doc2.verified().unwrap(), Some(current), repo, &alice.signer, )?; Ok(()) }) .unwrap(); let alice_did: Did = alice.signer.public_key().into(); // child1 has Alice's accept (she was the first to propose under this parent). assert!( alice_identity .revision(&child1) .unwrap() .accepted() .any(|did| did == alice_did), "child1 should have Alice's accept" ); // child2's implicit accept was stripped because Alice already accepted child1. assert!( !alice_identity .revision(&child2) .unwrap() .accepted() .any(|did| did == alice_did), "child2 should NOT have Alice's accept" ); // child2 still exists and is Active (just has 0 accept votes). assert_eq!( alice_identity.revision(&child2).unwrap().state, State::Active ); } /// The `accept()` API rejects an accept if the delegate already /// accepted an Active sibling. #[test] fn accept_rejects_sibling_accept() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let dave = &network.dave; // 4 delegates, majority = 3. This ensures Alice's accept of child_a // doesn't immediately adopt it (Bob + Alice = 2 < 3). let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); alice_doc.delegate(dave.signer.public_key().into()); let _a0 = alice_identity .update( cob::Title::new("Add delegates").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); // Bob proposes child_a. bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap(); let mut bob_doc = bob_identity.doc().clone().edit(); bob_doc.visibility = Visibility::private([]); let child_a = bob_identity .update( cob::Title::new("Child A").unwrap(), "", &bob_doc.verified().unwrap(), ) .unwrap(); // Eve proposes child_b (sibling of child_a). eve.repo.fetch(alice); let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap(); let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let child_b = eve_identity .update( cob::Title::new("Child B").unwrap(), "", &eve_doc.verified().unwrap(), ) .unwrap(); // Alice fetches both and accepts child_a. // child_a stays Active (Bob + Alice = 2/3, needs 3). alice.repo.fetch(bob); alice.repo.fetch(eve); alice_identity.reload().unwrap(); alice_identity.accept(&child_a).unwrap(); assert_eq!( alice_identity.revision(&child_a).unwrap().state, State::Active ); // Alice tries to accept child_b — should fail. let err = alice_identity.accept(&child_b).unwrap_err(); assert!( std::iter::successors(Some(&err as &dyn std::error::Error), |e| e.source()) .filter_map(|e| e.downcast_ref::()) .any(|e| matches!(e, ApplyError::SiblingAccepted { .. })), "Expected SiblingAccepted error, got: {err:?}" ); } /// The `update()` API rejects a second proposal if the delegate already /// has an accept on an Active sibling (child of current). #[test] fn update_rejects_second_sibling_proposal() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let _a1 = alice_identity .update( cob::Title::new("Add Bob").unwrap(), "", &alice_doc.verified().unwrap(), ) .unwrap(); // Alice proposes first child. let mut doc1 = alice_identity.doc().clone().edit(); doc1.visibility = Visibility::private([]); let _child1 = alice_identity .update( cob::Title::new("Child 1").unwrap(), "", &doc1.verified().unwrap(), ) .unwrap(); // Alice tries to propose a second child (sibling) — should fail. let mut doc2 = alice_identity.doc().clone().edit(); doc2.visibility = Visibility::private([alice.signer.public_key().into()]); let err = alice_identity .update( cob::Title::new("Child 2").unwrap(), "", &doc2.verified().unwrap(), ) .unwrap_err(); assert!( std::iter::successors(Some(&err as &dyn std::error::Error), |e| e.source()) .filter_map(|e| e.downcast_ref::()) .any(|e| matches!(e, ApplyError::SiblingAccepted { .. })), "Expected SiblingAccepted error, got: {err:?}" ); } }