use std::collections::BTreeMap;
use std::sync::LazyLock;
use std::{fmt, ops::Deref, str::FromStr};
use crypto::{PublicKey, Signature};
use radicle_cob::{Embed, ObjectId, TypeName};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::git;
use crate::git::Oid;
use crate::identity::doc::Doc;
use crate::node::device::Device;
use crate::node::NodeId;
use crate::storage;
use crate::{
cob,
cob::{
op, store,
store::{Cob, CobAction, Transaction},
ActorId, Timestamp, Uri,
},
identity::{
doc::{DocError, RepoId},
Did,
},
storage::{ReadRepository, RepositoryError, WriteRepository},
};
use super::{Author, EntryId};
pub static TYPENAME: LazyLock<TypeName> =
LazyLock::new(|| FromStr::from_str("xyz.radicle.id").expect("type name is valid"));
pub type Op = cob::Op<Action>;
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())
}
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Action {
#[serde(rename = "revision")]
Revision {
title: cob::Title,
#[serde(default, skip_serializing_if = "String::is_empty")]
description: String,
blob: Oid,
parent: Option<RevisionId>,
signature: Signature,
},
RevisionEdit {
revision: RevisionId,
title: cob::Title,
#[serde(default, skip_serializing_if = "String::is_empty")]
description: String,
},
#[serde(rename = "revision.accept")]
RevisionAccept {
revision: RevisionId,
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 { .. })
}
}
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum ApplyError {
#[error("causal dependency {0:?} missing")]
Missing(EntryId),
#[error("initialization failed: {0}")]
Init(&'static str),
#[error("invalid signature from {0} for blob {1}")]
InvalidSignature(PublicKey, Oid),
#[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("revision has been redacted")]
Redacted,
#[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(),
}
}
}
#[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),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Identity {
pub id: RepoId,
pub current: RevisionId,
pub root: RevisionId,
pub heads: BTreeMap<Did, RevisionId>,
revisions: BTreeMap<RevisionId, Option<Revision>>,
timeline: Vec<EntryId>,
}
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(revision: Revision) -> Self {
let root = revision.id;
Self {
id: revision.blob.into(),
root,
current: root,
heads: revision
.delegates()
.iter()
.copied()
.map(|did| (did, root))
.collect(),
revisions: BTreeMap::from_iter([(root, Some(revision))]),
timeline: vec![root],
}
}
pub fn initialize<'a, R, G>(
doc: &Doc,
store: &'a R,
signer: &Device<G>,
) -> Result<IdentityMut<'a, R>, cob::store::Error>
where
G: crypto::signature::Signer<crypto::Signature>,
R: WriteRepository + cob::Store<Namespace = NodeId>,
{
let mut store = cob::store::Store::open(store)?;
let (id, identity) = Transaction::<Identity, _>::initial(
"Initialize identity",
&mut store,
signer,
|tx, repo| {
tx.revision(
#[allow(clippy::unwrap_used)]
cob::Title::new("Initial revision").unwrap(),
"",
doc,
None,
repo,
signer,
)
},
)?;
Ok(IdentityMut {
id,
identity,
store,
})
}
pub fn get<R: ReadRepository + cob::Store>(
object: &ObjectId,
repo: &R,
) -> Result<Identity, store::Error> {
use cob::store::CobWithType;
cob::get::<Self, _>(repo, Self::type_name(), object)
.map(|r| r.map(|cob| cob.object))?
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *object))
}
pub fn get_mut<'a, R: WriteRepository + cob::Store<Namespace = NodeId>>(
id: &ObjectId,
repo: &'a R,
) -> Result<IdentityMut<'a, R>, store::Error> {
let obj = Self::get(id, repo)?;
let store = cob::store::Store::open(repo)?;
Ok(IdentityMut {
id: *id,
identity: obj,
store,
})
}
pub fn load<R: ReadRepository + cob::Store>(repo: &R) -> Result<Identity, RepositoryError> {
let oid = repo.identity_root()?;
let oid = ObjectId::from(oid);
Self::get(&oid, repo).map_err(RepositoryError::from)
}
pub fn load_mut<R: WriteRepository + cob::Store<Namespace = NodeId>>(
repo: &R,
) -> Result<IdentityMut<'_, R>, RepositoryError> {
let oid = repo.identity_root()?;
let oid = ObjectId::from(oid);
Self::get_mut(&oid, repo).map_err(RepositoryError::from)
}
}
impl Identity {
pub fn id(&self) -> RepoId {
self.id
}
pub fn doc(&self) -> &Doc {
&self.current().doc
}
pub fn current(&self) -> &Revision {
self.revision(&self.current)
.expect("Identity::current: the current revision must always exist")
}
pub fn root(&self) -> &Revision {
self.revision(&self.root)
.expect("Identity::root: the root revision must always exist")
}
pub fn head(&self) -> Oid {
self.current
}
pub fn revision(&self, revision: &RevisionId) -> Option<&Revision> {
self.revisions.get(revision).and_then(|r| r.as_ref())
}
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = &Revision> {
self.timeline
.iter()
.filter_map(|id| self.revisions.get(id).and_then(|o| o.as_ref()))
}
pub fn latest_by(&self, who: &Did) -> Option<&Revision> {
self.revisions().rev().find(|r| r.author.id() == who)
}
}
impl store::Cob for Identity {
type Action = Action;
type Error = ApplyError;
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
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"));
}
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<Item = &'a cob::Entry>>(
&mut self,
op: Op,
concurrent: I,
repo: &R,
) -> Result<(), ApplyError> {
let id = op.id;
let concurrent = concurrent.into_iter().collect::<Vec<_>>();
for action in op.actions {
match self.action(action, id, op.author, op.timestamp, &concurrent, repo) {
Ok(()) => {}
Err(ApplyError::UnexpectedState) => {
if concurrent.is_empty() {
return Err(ApplyError::UnexpectedState);
}
}
Err(ApplyError::Redacted) => {}
Err(other) => return Err(other),
}
debug_assert!(!self.timeline.contains(&id));
self.timeline.push(id);
}
Ok(())
}
}
impl Identity {
fn action<R: ReadRepository>(
&mut self,
action: Action,
entry: EntryId,
author: ActorId,
timestamp: Timestamp,
_concurrent: &[&cob::Entry],
repo: &R,
) -> Result<(), ApplyError> {
let current = self.current().clone();
let did = author.into();
if !current.is_delegate(&did) {
return Err(ApplyError::non_delegate_unauthorized(did, &action));
}
match action {
Action::RevisionAccept {
revision,
signature,
} => {
let id = revision;
let Some(revision) = lookup::revision_mut(&mut self.revisions, &id)? else {
return Err(ApplyError::Redacted);
};
if !revision.is_active() {
return Err(ApplyError::UnexpectedState);
}
assert_eq!(revision.parent, Some(current.id));
self.heads.insert(author.into(), id);
revision.accept(author, signature, ¤t)?;
self.adopt(id);
}
Action::RevisionReject { revision } => {
let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else {
return Err(ApplyError::Redacted);
};
if !revision.is_active() {
return Err(ApplyError::UnexpectedState);
}
assert_eq!(revision.parent, Some(current.id));
revision.reject(author)?;
}
Action::RevisionEdit {
title,
description,
revision,
} => {
if revision == self.current {
return Err(ApplyError::NotAuthorized);
}
let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else {
return Err(ApplyError::Redacted);
};
if !revision.is_active() {
return Err(ApplyError::UnexpectedState);
}
if revision.author.public_key() != &author {
return Err(ApplyError::NotAuthorized);
}
assert_eq!(revision.parent, Some(current.id));
revision.title = title;
revision.description = description;
}
Action::RevisionRedact { revision } => {
if revision == self.current {
return Err(ApplyError::UnexpectedState);
}
if let Some(revision) = self.revisions.get_mut(&revision) {
if let Some(r) = revision {
if r.is_accepted() {
return Err(ApplyError::UnexpectedState);
}
if r.author.public_key() != &author {
return Err(ApplyError::NotAuthorized);
}
*revision = None;
}
} else {
return Err(ApplyError::Missing(revision));
}
}
Action::Revision {
title,
description,
blob,
signature,
parent,
} => {
debug_assert!(!self.revisions.contains_key(&entry));
let doc = repo.blob(blob)?;
let doc = Doc::from_blob(&doc)?;
let Some(parent) = parent else {
return Err(ApplyError::MissingParent);
};
let Some(parent) = lookup::revision(&self.revisions, &parent)? else {
return Err(ApplyError::Redacted);
};
let state = if parent.id == current.id {
if doc == parent.doc {
return Err(ApplyError::DocUnchanged);
}
State::Active
} else {
State::Stale
};
if parent.verify_signature(&author, &signature, blob).is_err() {
return Err(ApplyError::InvalidSignature(author, blob));
}
let revision = Revision::new(
entry,
title,
description,
author.into(),
blob,
doc,
state,
signature,
Some(parent.id),
timestamp,
);
let id = revision.id;
self.heads.insert(author.into(), id);
self.revisions.insert(id, Some(revision));
if state == State::Active {
self.adopt(id);
}
}
}
Ok(())
}
fn adopt(&mut self, id: RevisionId) {
if self.current == id {
return;
}
let votes = self
.heads
.values()
.filter(|revision| **revision == id)
.count();
if self.is_majority(votes) {
self.current = id;
self.current_mut().state = State::Accepted;
for r in self
.revisions
.iter_mut()
.filter_map(|(_, r)| r.as_mut())
.filter(|r| r.state == State::Active)
{
r.state = State::Stale;
}
}
}
fn revision_mut(&mut self, revision: &RevisionId) -> Option<&mut Revision> {
self.revisions.get_mut(revision).and_then(|r| r.as_mut())
}
fn current_mut(&mut self) -> &mut Revision {
let current = self.current;
self.revision_mut(¤t)
.expect("Identity::current_mut: the current revision must always exist")
}
}
impl<R: ReadRepository> cob::Evaluate<R> for Identity {
type Error = Error;
fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
let op = Op::try_from(entry)?;
let object = Identity::from_root(op, repo)?;
Ok(object)
}
fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
&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 {
Accept(Signature),
Reject,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum State {
Active,
Accepted,
Rejected,
Stale,
}
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::Stale => write!(f, "stale"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Revision {
pub id: RevisionId,
pub blob: Oid,
pub title: cob::Title,
pub state: State,
pub description: String,
pub author: Author,
pub doc: Doc,
pub timestamp: Timestamp,
pub parent: Option<RevisionId>,
verdicts: BTreeMap<PublicKey, Verdict>,
}
impl std::ops::Deref for Revision {
type Target = Doc;
fn deref(&self) -> &Self::Target {
&self.doc
}
}
impl Revision {
pub fn signatures(&self) -> impl Iterator<Item = (&PublicKey, Signature)> {
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<Item = (&PublicKey, &Verdict)> {
self.verdicts.iter()
}
pub fn accepted(&self) -> impl Iterator<Item = Did> + '_ {
self.signatures().map(|(key, _)| key.into())
}
pub fn rejected(&self) -> impl Iterator<Item = Did> + '_ {
self.verdicts().filter_map(|(key, v)| match v {
Verdict::Accept(_) => None,
Verdict::Reject => Some(key.into()),
})
}
pub fn sign<G: crypto::signature::Signer<crypto::Signature>>(
&self,
signer: &G,
) -> Result<Signature, DocError> {
self.doc.signature_of(signer)
}
}
impl Revision {
fn new(
id: RevisionId,
title: cob::Title,
description: String,
author: Author,
blob: Oid,
doc: Doc,
state: State,
signature: Signature,
parent: Option<RevisionId>,
timestamp: Timestamp,
) -> Self {
let verdicts = BTreeMap::from_iter([(*author.public_key(), Verdict::Accept(signature))]);
Self {
id,
title,
description,
author,
blob,
doc,
state,
verdicts,
parent,
timestamp,
}
}
fn accept(
&mut self,
author: PublicKey,
signature: Signature,
current: &Revision,
) -> Result<(), ApplyError> {
if current
.verify_signature(&author, &signature, self.blob)
.is_err()
{
return Err(ApplyError::InvalidSignature(author, self.blob));
}
if self
.verdicts
.insert(author, Verdict::Accept(signature))
.is_some()
{
return Err(ApplyError::DuplicateVerdict);
}
Ok(())
}
fn reject(&mut self, key: PublicKey) -> Result<(), ApplyError> {
if self.verdicts.insert(key, Verdict::Reject).is_some() {
return Err(ApplyError::DuplicateVerdict);
}
if self.is_active() && self.rejected().count() > self.delegates().len() - self.majority() {
self.state = State::Rejected;
}
Ok(())
}
}
impl<R: ReadRepository> store::Transaction<Identity, R> {
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<R: WriteRepository> store::Transaction<Identity, R> {
pub fn revision<G: crypto::signature::Signer<crypto::Signature>>(
&mut self,
title: cob::Title,
description: impl ToString,
doc: &Doc,
parent: Option<RevisionId>,
repo: &R,
signer: &Device<G>,
) -> Result<(), store::Error> {
let (blob, bytes, signature) = doc.sign(signer).map_err(store::Error::Identity)?;
let embed =
Embed::<Uri>::store("radicle.json", &bytes, repo.raw()).map_err(store::Error::Git)?;
debug_assert_eq!(embed.content, Uri::from(blob));
self.embed([embed])?;
self.push(Action::Revision {
title,
description: description.to_string(),
blob,
parent,
signature,
})
}
}
pub struct IdentityMut<'a, R> {
pub id: ObjectId,
identity: Identity,
store: store::Store<'a, Identity, R>,
}
impl<R> fmt::Debug for IdentityMut<'_, R> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("IdentityMut")
.field("id", &self.id)
.field("identity", &self.identity)
.finish()
}
}
impl<R> IdentityMut<'_, R>
where
R: WriteRepository + cob::Store<Namespace = NodeId>,
{
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<G, F>(
&mut self,
message: &str,
signer: &Device<G>,
operations: F,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
F: FnOnce(&mut Transaction<Identity, R>, &R) -> 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, signer)?;
self.identity = doc;
Ok(commit)
}
pub fn update<G>(
&mut self,
title: cob::Title,
description: impl ToString,
doc: &Doc,
signer: &Device<G>,
) -> Result<RevisionId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
let parent = self.current;
let id = self.transaction("Propose revision", signer, |tx, repo| {
tx.revision(title, description, doc, Some(parent), repo, signer)
})?;
Ok(id)
}
pub fn accept<G>(&mut self, revision: &RevisionId, signer: &Device<G>) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
let id = *revision;
let revision = self.revision(revision).ok_or(Error::NotFound(id))?;
let signature = revision.sign(signer)?;
self.transaction("Accept revision", signer, |tx, _| tx.accept(id, signature))
}
pub fn reject<G>(&mut self, revision: RevisionId, signer: &Device<G>) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Reject revision", signer, |tx, _| tx.reject(revision))
}
pub fn redact<G>(&mut self, revision: RevisionId, signer: &Device<G>) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Redact revision", signer, |tx, _| tx.redact(revision))
}
pub fn edit<G>(
&mut self,
revision: RevisionId,
title: cob::Title,
description: String,
signer: &Device<G>,
) -> Result<EntryId, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
{
self.transaction("Edit revision", signer, |tx, _| {
tx.edit(revision, title, description)
})
}
}
impl<R> Deref for IdentityMut<'_, R> {
type Target = Identity;
fn deref(&self) -> &Self::Target {
&self.identity
}
}
mod lookup {
use super::*;
pub fn revision_mut<'a>(
revisions: &'a mut BTreeMap<RevisionId, Option<Revision>>,
revision: &RevisionId,
) -> Result<Option<&'a mut Revision>, ApplyError> {
match revisions.get_mut(revision) {
Some(Some(revision)) => Ok(Some(revision)),
Some(None) => Ok(None),
None => Err(ApplyError::Missing(*revision)),
}
}
pub fn revision<'a>(
revisions: &'a BTreeMap<RevisionId, Option<Revision>>,
revision: &RevisionId,
) -> Result<Option<&'a Revision>, ApplyError> {
match revisions.get(revision) {
Some(Some(revision)) => Ok(Some(revision)),
Some(None) => Ok(None),
None => Err(ApplyError::Missing(*revision)),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use qcheck_macros::quickcheck;
use crate::cob::{self, Title};
use crate::crypto::PublicKey;
use crate::identity::did::Did;
use crate::identity::doc::PayloadId;
use crate::identity::Visibility;
use crate::rad;
use crate::storage::git::Storage;
use crate::storage::ReadStorage;
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).unwrap();
let mut doc = identity.doc().clone().edit();
let title = Title::new("Identity update").unwrap();
let description = "";
let r0 = identity.current;
assert!(identity.current().is_accepted());
identity
.update(
title.clone(),
description,
&doc.clone().verified().unwrap(),
signer,
)
.unwrap_err();
assert_eq!(identity.current, r0);
doc.threshold = 2;
assert!(doc.clone().verified().is_err());
doc.delegate(bob.public_key().into());
let r1 = identity
.update(
title.clone(),
description,
&doc.clone().verified().unwrap(),
signer,
)
.unwrap();
assert!(identity.revision(&r1).unwrap().is_accepted());
assert_eq!(identity.current, r1);
doc.visibility = Visibility::private([]);
let r2 = identity
.update(
title.clone(),
description,
&doc.clone().verified().unwrap(),
signer,
)
.unwrap();
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
);
identity.accept(&r2, &bob).unwrap();
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).unwrap();
let mut doc = identity.doc().clone().edit();
let description = "";
doc.delegate(bob.public_key().into());
let r1 = identity
.update(
cob::Title::new("Identity update").unwrap(),
description,
&doc.clone().verified().unwrap(),
signer,
)
.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(),
&node.signer,
)
.unwrap();
identity.reject(r2, &bob).unwrap();
let r2 = identity.revision(&r2).unwrap();
assert_eq!(r2.state, State::Rejected);
doc.delegate(eve.public_key().into());
let r3 = identity
.update(
cob::Title::new("Add Eve").unwrap(),
description,
&doc.clone().verified().unwrap(),
&node.signer,
)
.unwrap();
let _ = identity.accept(&r3, &bob).unwrap();
assert_eq!(identity.current, r3);
doc.visibility = Visibility::Public;
let r3 = identity
.update(
cob::Title::new("Make public").unwrap(),
description,
&doc.verified().unwrap(),
&node.signer,
)
.unwrap();
identity.reject(r3, &bob).unwrap();
let r3 = identity.revision(&r3).unwrap().clone();
assert_eq!(r3.state, State::Active);
identity.reject(r3.id, &eve).unwrap();
let r3 = identity.revision(&r3.id).unwrap();
assert_eq!(r3.state, State::Rejected);
}
#[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).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(),
&alice.signer,
)
.unwrap();
bob.repo.fetch(alice);
let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
let bob_doc = bob_identity.doc().clone();
assert!(bob_doc.is_delegate(&bob.signer.public_key().into()));
alice_doc.visibility = Visibility::private([]);
let a2 = alice_identity
.update(
cob::Title::new("Change visibility").unwrap(),
"",
&alice_doc.clone().clone().verified().unwrap(),
&alice.signer,
)
.unwrap();
let b1 = bob_identity
.update(
cob::Title::new("Make private").unwrap(),
"",
&alice_doc.verified().unwrap(),
&bob.signer,
)
.unwrap();
bob.repo.fetch(alice);
bob_identity.reload().unwrap();
assert_eq!(bob_identity.current, a1);
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_identity.accept(&a2, &bob.signer).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::Stale);
}
#[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).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(),
&alice.signer,
)
.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(),
&alice.signer,
)
.unwrap();
bob.repo.fetch(alice);
let a3 = cob::stable::with_advanced_timestamp(|| {
alice_identity.redact(a2, &alice.signer).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).unwrap();
let b1 =
cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2, &bob.signer).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), None);
assert_eq!(bob_identity.current, a1);
}
#[test]
fn test_identity_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).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(),
&alice.signer,
)
.unwrap();
alice_doc.rescind(&eve.signer.public_key().into()).unwrap();
let a2 = alice_identity
.update(
cob::Title::new("Remove Eve").unwrap(),
"",
&alice_doc.verified().unwrap(),
&alice.signer,
)
.unwrap();
bob.repo.fetch(eve);
bob.repo.fetch(alice);
eve.repo.fetch(bob);
let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
let b1 =
cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2, &bob.signer).unwrap());
assert_eq!(bob_identity.current, a2);
let mut eve_identity = Identity::load_mut(&*eve.repo).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(),
&eve.signer,
)
.unwrap()
});
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, e1]);
assert!(eve_identity.revision(&e1).unwrap().is_active());
eve.repo.fetch(bob);
eve_identity.reload().unwrap();
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1]);
assert_eq!(eve_identity.revision(&e1), None);
assert!(!eve_identity.is_delegate(&eve.signer.public_key().into()));
}
#[test]
fn test_identity_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).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(),
&alice.signer,
)
.unwrap();
alice_doc.visibility = Visibility::private([]);
let a2 = alice_identity
.update(
cob::Title::new("Change visibility").unwrap(),
"",
&alice_doc.verified().unwrap(),
&alice.signer,
)
.unwrap();
bob.repo.fetch(eve);
bob.repo.fetch(alice);
eve.repo.fetch(bob);
let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
let b1 =
cob::stable::with_advanced_timestamp(|| bob_identity.accept(&a2, &bob.signer).unwrap());
let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap();
let e1 =
cob::stable::with_advanced_timestamp(|| eve_identity.reject(a2, &eve.signer).unwrap());
assert!(eve_identity.revision(&a2).unwrap().is_active());
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(),
&eve.signer,
)
.unwrap();
assert!(eve_identity.revision(&e2).unwrap().is_active());
eve.repo.fetch(bob);
eve_identity.reload().unwrap();
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1, e2]);
let e2 = eve_identity.revision(&e2).unwrap();
assert_eq!(e2.state, State::Stale);
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).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(),
&alice.signer,
)
.unwrap();
bob.repo.fetch(alice);
eve.repo.fetch(alice);
let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
let mut bob_doc = bob_identity.doc().clone().edit();
assert!(bob_doc.is_delegate(&bob.signer.public_key().into()));
bob_doc.visibility = Visibility::private([]);
let b1 = bob_identity
.update(
cob::Title::new("Change visibility #1").unwrap(),
"",
&bob_doc.verified().unwrap(),
&bob.signer,
)
.unwrap();
alice.repo.fetch(bob);
eve.repo.fetch(bob);
let mut eve_identity = Identity::load_mut(&*eve.repo).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(),
&eve.signer,
)
.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, &alice.signer).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::Stale);
}
#[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();
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).unwrap();
let doc = identity.doc().clone();
let prj = doc.project().unwrap();
let mut doc = doc.edit();
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(),
&alice,
)
.unwrap();
doc.delegate(bob.public_key().into());
doc.threshold = 2;
identity
.update(
cob::Title::new("Add bob").unwrap(),
"",
&doc.clone().verified().unwrap(),
&alice,
)
.unwrap();
doc.delegate(eve.public_key().into());
let revision = identity
.update(
cob::Title::new("Add eve").unwrap(),
"",
&doc.clone().verified().unwrap(),
&alice,
)
.unwrap();
identity.accept(&revision, &bob).unwrap();
let desc = prj.description().to_owned() + "?";
let prj = prj.update(None, desc, None).unwrap();
doc.payload.insert(PayloadId::project(), prj.into());
let revision = identity
.update(
cob::Title::new("Update description again").unwrap(),
"Bob's repository",
&doc.verified().unwrap(),
&bob,
)
.unwrap();
identity.accept(&revision, &eve).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!(identity.id(), 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!?");
}
}