radicle/cob/identity: Rewrite Evaluation
The evaluation of the reposioty identity is rewritten to better handle cases
where there are child and sibling revisions that are active at the same time.
In particular, `fn Identity::action` is now free of any references to
`self.current`.
This is achieved through two main improvements. The first is that the `State`
of revisions changes to improve clarity:
1. `Active` and `Accepted` remain, and their meanings also remain the same.
2. `Stale` is removed entirely.
3. `Rejected` is improved to also contain `RejectedBy`, to keep track of the
reason for rejection.
4. `Redacted` is promoted to a state. Similarly, the reason for
redaction is tracked by `RedactedBy`.
The transition of a revision from `Active` to one of the other states now
influences siblings and children.
If a revision transitions to `Accepted`, then the sibling revisions can no
longer transition to `Accepted`. They are considered `Rejected` where the reason
is `Sibling`. This cascades: Children of siblings are `Rejected`
recursively, tracking `RejectedBy::Parent`.
Any children of the `Accepted` revision are also evaluated to see if they can be
similarly transitioned to the `Accepted` state; since they may have already been
voted on.
If a revision was rejected by a majority, then the it transitions to `Rejected`
with `RejectedBy::Vote`. Dually to acceptance, the children of this revision
are also rejected with the reason of `Ancestor`.
Finally, when the author of a revision redacts a revision, it transitions
to `Redacted`, tracking `RedactedBy::Author`, and its children are redacted
tracking `RedactedBy::Parent`.
The test is adjusted to `remove_delegate_concurrent` reflect that concurrently
proposed revisions are retained in the timeline and explicitly marked as rejected,
rather than being dropped entirely (as before).
This commit is contained in:
parent
b145b95ee4
commit
d8c14222a2
|
|
@ -53,7 +53,7 @@ $ rad id list
|
||||||
│ ● ID Title Author Status Created Parent │
|
│ ● ID Title Author Status Created Parent │
|
||||||
├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
│ ● 89b2623 Edit project name bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk accepted now 0ca42d3 │
|
│ ● 89b2623 Edit project name bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk accepted now 0ca42d3 │
|
||||||
│ ● 12d7300 Edit project name alice (you) stale now 0ca42d3 │
|
│ ● 12d7300 Edit project name alice (you) rejected now 0ca42d3 │
|
||||||
│ ● 0ca42d3 Add Bob alice (you) accepted now 0656c21 │
|
│ ● 0ca42d3 Add Bob alice (you) accepted now 0656c21 │
|
||||||
│ ● 0656c21 Initial revision alice (you) accepted now none │
|
│ ● 0656c21 Initial revision alice (you) accepted now none │
|
||||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
|
@ -70,9 +70,9 @@ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential s
|
||||||
```
|
```
|
||||||
``` ~bob (fail)
|
``` ~bob (fail)
|
||||||
$ rad id accept 12d7300 -q
|
$ rad id accept 12d7300 -q
|
||||||
✗ Error: cannot vote on revision that is stale
|
✗ Error: cannot vote on revision that is rejected
|
||||||
$ rad id reject 12d7300 -q
|
$ rad id reject 12d7300 -q
|
||||||
✗ Error: cannot vote on revision that is stale
|
✗ Error: cannot vote on revision that is rejected
|
||||||
```
|
```
|
||||||
``` ~bob
|
``` ~bob
|
||||||
$ rad id show 12d7300
|
$ rad id show 12d7300
|
||||||
|
|
@ -82,7 +82,7 @@ $ rad id show 12d7300
|
||||||
│ Parent 0ca42d376bd566631083c8913cf86bec722da392 │
|
│ Parent 0ca42d376bd566631083c8913cf86bec722da392 │
|
||||||
│ Blob e93aa3e3c5c448bacd3537a81daf1437eccd046a │
|
│ Blob e93aa3e3c5c448bacd3537a81daf1437eccd046a │
|
||||||
│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
|
│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
|
||||||
│ State stale │
|
│ State rejected │
|
||||||
│ Quorum no │
|
│ Quorum no │
|
||||||
├────────────────────────────────────────────────────────────────────────┤
|
├────────────────────────────────────────────────────────────────────────┤
|
||||||
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice │
|
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice │
|
||||||
|
|
|
||||||
|
|
@ -222,8 +222,8 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
let icon = match r.state {
|
let icon = match r.state {
|
||||||
identity::State::Active => term::format::tertiary("●"),
|
identity::State::Active => term::format::tertiary("●"),
|
||||||
identity::State::Accepted => term::format::positive("●"),
|
identity::State::Accepted => term::format::positive("●"),
|
||||||
identity::State::Rejected => term::format::negative("●"),
|
identity::State::Rejected(_) => term::format::negative("●"),
|
||||||
identity::State::Stale => term::format::dim("●"),
|
identity::State::Redacted(_) => continue,
|
||||||
}
|
}
|
||||||
.into();
|
.into();
|
||||||
let state = r.state.to_string().into();
|
let state = r.state.to_string().into();
|
||||||
|
|
@ -288,6 +288,7 @@ fn get<'a>(
|
||||||
let id = revision.resolve(&repo.backend)?;
|
let id = revision.resolve(&repo.backend)?;
|
||||||
let revision = identity
|
let revision = identity
|
||||||
.revision(&id)
|
.revision(&id)
|
||||||
|
.filter(|revision| !matches!(revision.state, identity::State::Redacted(_)))
|
||||||
.ok_or(anyhow!("revision `{id}` not found"))?;
|
.ok_or(anyhow!("revision `{id}` not found"))?;
|
||||||
|
|
||||||
Ok(revision)
|
Ok(revision)
|
||||||
|
|
@ -344,13 +345,28 @@ fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow:
|
||||||
})
|
})
|
||||||
.divider();
|
.divider();
|
||||||
|
|
||||||
let accepted = revision.accepted().collect::<Vec<_>>();
|
let accepted = {
|
||||||
let rejected = revision.rejected().collect::<Vec<_>>();
|
let mut accepted = revision.accepted().collect::<Vec<_>>();
|
||||||
let unknown = previous
|
accepted.sort();
|
||||||
.delegates()
|
accepted
|
||||||
.iter()
|
};
|
||||||
.filter(|id| !accepted.contains(id) && !rejected.contains(id))
|
|
||||||
.collect::<Vec<_>>();
|
let rejected = {
|
||||||
|
let mut rejected = revision.rejected().collect::<Vec<_>>();
|
||||||
|
rejected.sort();
|
||||||
|
rejected
|
||||||
|
};
|
||||||
|
|
||||||
|
let unknown = {
|
||||||
|
let mut unknown = previous
|
||||||
|
.delegates()
|
||||||
|
.iter()
|
||||||
|
.filter(|id| !accepted.contains(id) && !rejected.contains(id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
unknown.sort();
|
||||||
|
unknown
|
||||||
|
};
|
||||||
|
|
||||||
let mut signatures = term::Table::<4, _>::default();
|
let mut signatures = term::Table::<4, _>::default();
|
||||||
|
|
||||||
for id in accepted {
|
for id in accepted {
|
||||||
|
|
@ -485,7 +501,6 @@ fn on_apply_err(e: &identity::ApplyError, profile: &Profile) -> anyhow::Error {
|
||||||
| e @ radicle::cob::identity::ApplyError::MissingParent
|
| e @ radicle::cob::identity::ApplyError::MissingParent
|
||||||
| e @ radicle::cob::identity::ApplyError::DuplicateVerdict
|
| e @ radicle::cob::identity::ApplyError::DuplicateVerdict
|
||||||
| e @ radicle::cob::identity::ApplyError::UnexpectedState
|
| e @ radicle::cob::identity::ApplyError::UnexpectedState
|
||||||
| e @ radicle::cob::identity::ApplyError::Redacted
|
|
||||||
| e @ radicle::cob::identity::ApplyError::DocUnchanged
|
| e @ radicle::cob::identity::ApplyError::DocUnchanged
|
||||||
| e @ radicle::cob::identity::ApplyError::Git(_)
|
| e @ radicle::cob::identity::ApplyError::Git(_)
|
||||||
| e @ radicle::cob::identity::ApplyError::Doc(_)
|
| e @ radicle::cob::identity::ApplyError::Doc(_)
|
||||||
|
|
|
||||||
|
|
@ -405,8 +405,7 @@ pub mod identity {
|
||||||
match s {
|
match s {
|
||||||
State::Active => term::format::tertiary(s.to_string()),
|
State::Active => term::format::tertiary(s.to_string()),
|
||||||
State::Accepted => term::format::positive(s.to_string()),
|
State::Accepted => term::format::positive(s.to_string()),
|
||||||
State::Rejected => term::format::negative(s.to_string()),
|
State::Rejected(_) | State::Redacted(_) => term::format::negative(s.to_string()),
|
||||||
State::Stale => term::format::dim(s.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::BTreeMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::{fmt, ops::Deref, str::FromStr};
|
use std::{fmt, ops::Deref, str::FromStr};
|
||||||
|
|
||||||
|
|
@ -122,8 +122,6 @@ pub enum ApplyError {
|
||||||
DuplicateVerdict,
|
DuplicateVerdict,
|
||||||
#[error("revision is in an unexpected state")]
|
#[error("revision is in an unexpected state")]
|
||||||
UnexpectedState,
|
UnexpectedState,
|
||||||
#[error("revision has been redacted")]
|
|
||||||
Redacted,
|
|
||||||
#[error("document does not contain any changes to current identity")]
|
#[error("document does not contain any changes to current identity")]
|
||||||
DocUnchanged,
|
DocUnchanged,
|
||||||
#[error("git: {0}")]
|
#[error("git: {0}")]
|
||||||
|
|
@ -179,7 +177,7 @@ pub struct Identity {
|
||||||
pub root: RevisionId,
|
pub root: RevisionId,
|
||||||
|
|
||||||
/// Revisions.
|
/// Revisions.
|
||||||
revisions: BTreeMap<RevisionId, Option<Revision>>,
|
revisions: HashMap<RevisionId, Revision>,
|
||||||
/// Timeline of events.
|
/// Timeline of events.
|
||||||
timeline: Vec<EntryId>,
|
timeline: Vec<EntryId>,
|
||||||
}
|
}
|
||||||
|
|
@ -199,15 +197,15 @@ impl std::ops::Deref for Identity {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Identity {
|
impl Identity {
|
||||||
pub fn new(revision: Revision) -> Self {
|
pub fn new(root: Revision) -> Self {
|
||||||
let root = revision.id;
|
let root_id = root.id;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: revision.blob.into(),
|
id: root.blob.into(),
|
||||||
root,
|
root: root_id,
|
||||||
current: root,
|
current: root_id,
|
||||||
revisions: BTreeMap::from_iter([(root, Some(revision))]),
|
revisions: HashMap::from_iter([(root_id, root)]),
|
||||||
timeline: vec![root],
|
timeline: vec![root_id],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,19 +329,43 @@ impl Identity {
|
||||||
|
|
||||||
/// A specific [`Revision`], that may be redacted.
|
/// A specific [`Revision`], that may be redacted.
|
||||||
pub fn revision(&self, revision: &RevisionId) -> Option<&Revision> {
|
pub fn revision(&self, revision: &RevisionId) -> Option<&Revision> {
|
||||||
self.revisions.get(revision).and_then(|r| r.as_ref())
|
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.
|
/// All the [`Revision`]s that have not been redacted.
|
||||||
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = &Revision> {
|
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = &Revision> {
|
||||||
self.timeline
|
self.timeline.iter().filter_map(|id| {
|
||||||
.iter()
|
self.revisions
|
||||||
.filter_map(|id| self.revisions.get(id).and_then(|o| o.as_ref()))
|
.get(id)
|
||||||
|
.filter(|revision| !matches!(revision.state, State::Redacted(_)))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn latest_by(&self, who: &Did) -> Option<&Revision> {
|
pub fn latest_by(&self, who: &Did) -> Option<&Revision> {
|
||||||
self.revisions().rev().find(|r| r.author.id() == who)
|
self.revisions().rev().find(|r| r.author.id() == who)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn children_of(&self, id: &RevisionId) -> impl Iterator<Item = &RevisionId> {
|
||||||
|
self.revision(id)
|
||||||
|
.map(|revision| &revision.children)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn siblings_of(&self, id: &RevisionId) -> impl Iterator<Item = &RevisionId> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl store::Cob for Identity {
|
impl store::Cob for Identity {
|
||||||
|
|
@ -422,19 +444,14 @@ impl store::Cob for Identity {
|
||||||
let concurrent = concurrent.into_iter().collect::<Vec<_>>();
|
let concurrent = concurrent.into_iter().collect::<Vec<_>>();
|
||||||
|
|
||||||
for action in op.actions {
|
for action in op.actions {
|
||||||
match self.action(action, id, op.author, op.timestamp, &concurrent, repo) {
|
match self.action(action, id, op.author, op.timestamp, repo) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
// This particular error is returned when there is a mismatch between the expected
|
// 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
|
// 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.
|
// if there are other concurrent ops, it is not fatal and we simply ignore it.
|
||||||
Err(ApplyError::UnexpectedState) => {
|
Err(ApplyError::UnexpectedState) if !concurrent.is_empty() => {}
|
||||||
if concurrent.is_empty() {
|
// It is not a user error if the revision happens to be redacted by
|
||||||
return Err(ApplyError::UnexpectedState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// It's not a user error if the revision happens to be redacted by
|
|
||||||
// the time this action is processed.
|
// the time this action is processed.
|
||||||
Err(ApplyError::Redacted) => {}
|
|
||||||
Err(other) => return Err(other),
|
Err(other) => return Err(other),
|
||||||
}
|
}
|
||||||
debug_assert!(!self.timeline.contains(&id));
|
debug_assert!(!self.timeline.contains(&id));
|
||||||
|
|
@ -452,138 +469,195 @@ impl Identity {
|
||||||
/// * There is only ever one accepted revision; this is the "current" revision.
|
/// * 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.
|
/// * There can be zero or more active revisions, up to the number of delegates.
|
||||||
/// * An active revision is one that can be "voted" on.
|
/// * An active revision is one that can be "voted" on.
|
||||||
/// * An active revision always has the current revision as parent.
|
/// * Only an active revision can be accepted, rejected or edited.
|
||||||
/// * Only the active revision can be accepted, rejected or edited.
|
|
||||||
fn action<R: ReadRepository>(
|
fn action<R: ReadRepository>(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: Action,
|
action: Action,
|
||||||
entry: EntryId,
|
id: EntryId,
|
||||||
author: ActorId,
|
author: ActorId,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
_concurrent: &[&cob::Entry],
|
|
||||||
repo: &R,
|
repo: &R,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
let current = self.current().clone();
|
|
||||||
|
|
||||||
let did = author.into();
|
let did = author.into();
|
||||||
if !current.is_delegate(&did) {
|
|
||||||
return Err(ApplyError::non_delegate_unauthorized(did, &action));
|
|
||||||
}
|
|
||||||
match action {
|
match action {
|
||||||
Action::RevisionAccept {
|
action @ (Action::RevisionAccept { revision: id, .. }
|
||||||
revision,
|
| Action::RevisionReject { revision: id }) => {
|
||||||
signature,
|
let noun = match action {
|
||||||
} => {
|
Action::RevisionAccept { .. } => "acceptance",
|
||||||
let id = revision;
|
Action::RevisionReject { .. } => "rejection",
|
||||||
let Some(revision) = lookup::revision_mut(&mut self.revisions, &id)? else {
|
_ => unreachable!(),
|
||||||
return Err(ApplyError::Redacted);
|
|
||||||
};
|
};
|
||||||
if !revision.is_active() {
|
|
||||||
// You can't vote on an inactive revision.
|
let revision = self.revision(&id).ok_or(ApplyError::Missing(id))?;
|
||||||
return Err(ApplyError::UnexpectedState);
|
|
||||||
|
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 = revision.parent.ok_or(ApplyError::MissingParent)?;
|
||||||
|
let parent = self.revision(&parent).ok_or(ApplyError::Missing(parent))?;
|
||||||
|
|
||||||
|
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, .. } => {
|
||||||
|
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!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(revision.parent, Some(current.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() {
|
|
||||||
// You can't vote on an inactive revision.
|
|
||||||
return Err(ApplyError::UnexpectedState);
|
|
||||||
}
|
|
||||||
assert_eq!(revision.parent, Some(current.id));
|
|
||||||
|
|
||||||
revision.reject(author)?;
|
|
||||||
}
|
}
|
||||||
Action::RevisionEdit {
|
Action::RevisionEdit {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
revision,
|
revision: id,
|
||||||
} => {
|
} => {
|
||||||
if revision == self.current {
|
let revision = self.revision_mut(&id)?;
|
||||||
return Err(ApplyError::NotAuthorized);
|
|
||||||
}
|
|
||||||
let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else {
|
|
||||||
return Err(ApplyError::Redacted);
|
|
||||||
};
|
|
||||||
if !revision.is_active() {
|
if !revision.is_active() {
|
||||||
// You can't edit an inactive revision.
|
log::debug!("Cannot edit revision {id} because it is not active.",);
|
||||||
return Err(ApplyError::UnexpectedState);
|
return Err(ApplyError::UnexpectedState);
|
||||||
}
|
}
|
||||||
if revision.author.public_key() != &author {
|
if revision.author.public_key() != &author {
|
||||||
// Can't edit someone else's revision.
|
log::debug!(
|
||||||
|
"{} cannot edit revision created by {}.",
|
||||||
|
author,
|
||||||
|
revision.author.public_key()
|
||||||
|
);
|
||||||
// Since the author never changes, we can safely mark this as invalid.
|
// Since the author never changes, we can safely mark this as invalid.
|
||||||
return Err(ApplyError::NotAuthorized);
|
return Err(ApplyError::NotAuthorized);
|
||||||
}
|
}
|
||||||
assert_eq!(revision.parent, Some(current.id));
|
|
||||||
|
|
||||||
revision.title = title;
|
revision.title = title;
|
||||||
revision.description = description;
|
revision.description = description;
|
||||||
}
|
}
|
||||||
Action::RevisionRedact { revision } => {
|
Action::RevisionRedact { revision: id } => {
|
||||||
if revision == self.current {
|
let revision = self.revision_mut(&id)?;
|
||||||
// Can't redact the current revision.
|
|
||||||
return Err(ApplyError::UnexpectedState);
|
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 let Some(revision) = self.revisions.get_mut(&revision) {
|
|
||||||
if let Some(r) = revision {
|
if !revision.is_active() {
|
||||||
if r.is_accepted() {
|
log::debug!("Cannot redact inactive revision {id}.");
|
||||||
// You can't redact an accepted revision.
|
return Ok(());
|
||||||
return Err(ApplyError::UnexpectedState);
|
|
||||||
}
|
|
||||||
if r.author.public_key() != &author {
|
|
||||||
// Can't redact someone else's revision.
|
|
||||||
// Since the author never changes, we can safely mark this as invalid.
|
|
||||||
return Err(ApplyError::NotAuthorized);
|
|
||||||
}
|
|
||||||
*revision = None;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(ApplyError::Missing(revision));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::debug!("Redacting revision {id}.");
|
||||||
|
revision.state = State::Redacted(RedactedBy::Author);
|
||||||
|
|
||||||
|
self.cascade(id, State::Redacted(RedactedBy::Parent));
|
||||||
}
|
}
|
||||||
Action::Revision {
|
Action::Revision {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
blob,
|
blob,
|
||||||
signature,
|
signature,
|
||||||
parent,
|
parent: parent_id,
|
||||||
} => {
|
} => {
|
||||||
debug_assert!(!self.revisions.contains_key(&entry));
|
debug_assert_eq!(self.revisions.get(&id), None, "revision visited twice");
|
||||||
|
|
||||||
|
let doc = Doc::from_blob(&repo.blob(blob)?)?;
|
||||||
|
|
||||||
let doc = repo.blob(blob)?;
|
|
||||||
let doc = Doc::from_blob(&doc)?;
|
|
||||||
// All revisions but the first one must have a parent.
|
// All revisions but the first one must have a parent.
|
||||||
let Some(parent) = parent else {
|
let parent_id = parent_id.ok_or(ApplyError::MissingParent)?;
|
||||||
return Err(ApplyError::MissingParent);
|
let parent = self.revision(&parent_id).ok_or(ApplyError::MissingParent)?;
|
||||||
};
|
|
||||||
let Some(parent) = lookup::revision(&self.revisions, &parent)? else {
|
if !parent.is_delegate(&did) {
|
||||||
return Err(ApplyError::Redacted);
|
return Err(ApplyError::NonDelegateUnauthorized {
|
||||||
};
|
author: author.into(),
|
||||||
// If the parent of this revision is no longer the current document, this
|
action: "create a revision".to_string(),
|
||||||
// revision can be marked as outdated.
|
});
|
||||||
let state = if parent.id == current.id {
|
}
|
||||||
// If the revision is not outdated, we expect it to make a change to the
|
|
||||||
// current version.
|
// We expect the revision to make a change compared to its parent.
|
||||||
if doc == parent.doc {
|
if doc == parent.doc {
|
||||||
return Err(ApplyError::DocUnchanged);
|
return Err(ApplyError::DocUnchanged);
|
||||||
}
|
}
|
||||||
State::Active
|
|
||||||
} else {
|
|
||||||
State::Stale
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify signature over new blob, using trusted delegates.
|
// Verify signature over new blob, using trusted delegates.
|
||||||
if parent.verify_signature(&author, &signature, blob).is_err() {
|
if parent.verify_signature(&author, &signature, blob).is_err() {
|
||||||
return Err(ApplyError::InvalidSignature(author, blob));
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
let revision = Revision::new(
|
let revision = Revision::new(
|
||||||
entry,
|
id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
author.into(),
|
author.into(),
|
||||||
|
|
@ -591,12 +665,12 @@ impl Identity {
|
||||||
doc,
|
doc,
|
||||||
state,
|
state,
|
||||||
signature,
|
signature,
|
||||||
Some(parent.id),
|
Some(parent_id),
|
||||||
timestamp,
|
timestamp,
|
||||||
);
|
);
|
||||||
let id = revision.id;
|
|
||||||
|
|
||||||
self.revisions.insert(id, Some(revision));
|
self.revisions.insert(id, revision);
|
||||||
|
self.revision_mut(&parent_id)?.children.push(id);
|
||||||
|
|
||||||
if state == State::Active {
|
if state == State::Active {
|
||||||
self.adopt(id);
|
self.adopt(id);
|
||||||
|
|
@ -606,41 +680,134 @@ impl Identity {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to adopt a revision as the current one.
|
/// 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) {
|
fn adopt(&mut self, id: RevisionId) {
|
||||||
if self.current == id {
|
if self.current == id {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let votes = self
|
|
||||||
.revision(&id)
|
|
||||||
.map(|revision| revision.accepted().count())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if self.is_majority(votes) {
|
|
||||||
self.current = id;
|
|
||||||
self.current_mut().state = State::Accepted;
|
|
||||||
|
|
||||||
// Void all other active revisions.
|
let candidate = self.revision(&id).expect("revision exists");
|
||||||
for r in self
|
|
||||||
.revisions
|
assert_eq!(candidate.state, State::Active);
|
||||||
.iter_mut()
|
|
||||||
.filter_map(|(_, r)| r.as_mut())
|
let parent = candidate.parent.expect("revision has parent");
|
||||||
.filter(|r| r.state == State::Active)
|
if parent != self.current {
|
||||||
{
|
log::debug!(
|
||||||
r.state = State::Stale;
|
"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::<Vec<_>>() {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
// 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::<Vec<_>>();
|
||||||
|
|
||||||
|
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.
|
/// A specific [`Revision`], mutably.
|
||||||
fn revision_mut(&mut self, revision: &RevisionId) -> Option<&mut Revision> {
|
///
|
||||||
self.revisions.get_mut(revision).and_then(|r| r.as_mut())
|
/// # 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));
|
||||||
|
|
||||||
/// The current revision, mutably.
|
#[cfg(debug_assertions)]
|
||||||
fn current_mut(&mut self) -> &mut Revision {
|
if let Some(actual_id) = revision.as_ref().ok().map(|r| r.id) {
|
||||||
let current = self.current;
|
debug_assert_eq!(actual_id, *id)
|
||||||
self.revision_mut(¤t)
|
}
|
||||||
.expect("Identity::current_mut: the current revision must always exist")
|
|
||||||
|
revision
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -680,18 +847,49 @@ pub enum Verdict {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum State {
|
pub enum State {
|
||||||
/// The revision is actively being voted on. From here, it can go into any of the
|
/// The initial state of any revision.
|
||||||
/// other states.
|
///
|
||||||
|
/// 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,
|
Active,
|
||||||
/// The revision has been accepted by a majority of delegates. Once accepted,
|
/// The revision was accepted by a majority of delegates.
|
||||||
/// a revision doesn't change state.
|
/// Accepted revisions cannot be redacted or rejected.
|
||||||
Accepted,
|
Accepted,
|
||||||
/// The revision was rejected by a majority of delegates. Once rejected,
|
/// The revision was rejected by a majority of delegates, or
|
||||||
/// a revision doesn't change state.
|
/// a sibling revision was accepted by a majority of delegates or
|
||||||
Rejected,
|
/// an ancestor was rejected.
|
||||||
/// The revision was active, but has been replaced by another revision,
|
Rejected(RejectedBy),
|
||||||
/// and is now outdated. Once stale, a revision doesn't change state.
|
/// The author decided to redact/withdraw the revision, or
|
||||||
Stale,
|
/// 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 {
|
impl std::fmt::Display for State {
|
||||||
|
|
@ -699,8 +897,43 @@ impl std::fmt::Display for State {
|
||||||
match self {
|
match self {
|
||||||
Self::Active => write!(f, "active"),
|
Self::Active => write!(f, "active"),
|
||||||
Self::Accepted => write!(f, "accepted"),
|
Self::Accepted => write!(f, "accepted"),
|
||||||
Self::Rejected => write!(f, "rejected"),
|
Self::Rejected(_) => write!(f, "rejected"),
|
||||||
Self::Stale => write!(f, "stale"),
|
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}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -733,7 +966,10 @@ pub struct Revision {
|
||||||
pub parent: Option<RevisionId>,
|
pub parent: Option<RevisionId>,
|
||||||
|
|
||||||
/// Signatures and rejections given by the delegates.
|
/// Signatures and rejections given by the delegates.
|
||||||
verdicts: BTreeMap<PublicKey, Verdict>,
|
verdicts: HashMap<PublicKey, Verdict>,
|
||||||
|
|
||||||
|
/// Children of this revision.
|
||||||
|
children: Vec<RevisionId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::ops::Deref for Revision {
|
impl std::ops::Deref for Revision {
|
||||||
|
|
@ -797,7 +1033,7 @@ impl Revision {
|
||||||
parent: Option<RevisionId>,
|
parent: Option<RevisionId>,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let verdicts = BTreeMap::from_iter([(*author.public_key(), Verdict::Accept(signature))]);
|
let verdicts = HashMap::from_iter([(*author.public_key(), Verdict::Accept(signature))]);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
|
|
@ -809,45 +1045,10 @@ impl Revision {
|
||||||
state,
|
state,
|
||||||
verdicts,
|
verdicts,
|
||||||
parent,
|
parent,
|
||||||
|
children: Vec::new(),
|
||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accept(
|
|
||||||
&mut self,
|
|
||||||
author: PublicKey,
|
|
||||||
signature: Signature,
|
|
||||||
current: &Revision,
|
|
||||||
) -> Result<(), ApplyError> {
|
|
||||||
// Check that this is a valid signature over the new document blob id.
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
// Mark as rejected if it's impossible for this revision to be accepted
|
|
||||||
// with the current delegate set. Note that if the delegate set changes,
|
|
||||||
// this proposal will be marked as `stale` anyway.
|
|
||||||
if self.is_active() && self.rejected().count() > self.delegates().len() - self.majority() {
|
|
||||||
self.state = State::Rejected;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: ReadRepository> store::Transaction<Identity, R> {
|
impl<R: ReadRepository> store::Transaction<Identity, R> {
|
||||||
|
|
@ -1030,36 +1231,6 @@ impl<Repo, Signer> Deref for IdentityMut<'_, '_, Repo, Signer> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)),
|
|
||||||
// Redacted.
|
|
||||||
Some(None) => Ok(None),
|
|
||||||
// Missing. Causal error.
|
|
||||||
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)),
|
|
||||||
// Redacted.
|
|
||||||
Some(None) => Ok(None),
|
|
||||||
// Missing. Causal error.
|
|
||||||
None => Err(ApplyError::Missing(*revision)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
mod test {
|
mod test {
|
||||||
|
|
@ -1072,7 +1243,7 @@ mod test {
|
||||||
use crate::identity::doc::PayloadId;
|
use crate::identity::doc::PayloadId;
|
||||||
use crate::node::device::Device;
|
use crate::node::device::Device;
|
||||||
use crate::rad;
|
use crate::rad;
|
||||||
use crate::storage::ReadStorage;
|
use crate::storage::ReadStorage as _;
|
||||||
use crate::storage::git::Storage;
|
use crate::storage::git::Storage;
|
||||||
use crate::test::fixtures;
|
use crate::test::fixtures;
|
||||||
use crate::test::setup::{Network, NodeWithRepo};
|
use crate::test::setup::{Network, NodeWithRepo};
|
||||||
|
|
@ -1190,7 +1361,7 @@ mod test {
|
||||||
// 1/2 rejected means that we can never reach the required 2/2 votes.
|
// 1/2 rejected means that we can never reach the required 2/2 votes.
|
||||||
bob_identity.reject(r2).unwrap();
|
bob_identity.reject(r2).unwrap();
|
||||||
let r2 = bob_identity.revision(&r2).unwrap();
|
let r2 = bob_identity.revision(&r2).unwrap();
|
||||||
assert_eq!(r2.state, State::Rejected);
|
assert_eq!(r2.state, State::Rejected(RejectedBy::Vote));
|
||||||
|
|
||||||
// Now let's add another delegate.
|
// Now let's add another delegate.
|
||||||
doc.delegate(eve.public_key().into());
|
doc.delegate(eve.public_key().into());
|
||||||
|
|
@ -1227,7 +1398,7 @@ mod test {
|
||||||
// 2/3 rejected means that we can no longer reach the 2/3 required votes.
|
// 2/3 rejected means that we can no longer reach the 2/3 required votes.
|
||||||
eve_identity.reject(r3.id).unwrap();
|
eve_identity.reject(r3.id).unwrap();
|
||||||
let r3 = eve_identity.revision(&r3.id).unwrap();
|
let r3 = eve_identity.revision(&r3.id).unwrap();
|
||||||
assert_eq!(r3.state, State::Rejected);
|
assert_eq!(r3.state, State::Rejected(RejectedBy::Vote));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1295,7 +1466,10 @@ mod test {
|
||||||
assert_eq!(bob_identity.current, a2);
|
assert_eq!(bob_identity.current, a2);
|
||||||
assert_eq!(bob_identity.revision(&a1).unwrap().state, State::Accepted);
|
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(&a2).unwrap().state, State::Accepted);
|
||||||
assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Stale);
|
assert_eq!(
|
||||||
|
bob_identity.revision(&b1).unwrap().state,
|
||||||
|
State::Rejected(RejectedBy::Sibling(a2))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1341,12 +1515,15 @@ mod test {
|
||||||
bob_identity.reload().unwrap();
|
bob_identity.reload().unwrap();
|
||||||
|
|
||||||
assert_eq!(bob_identity.timeline, vec![a0, a1, a2, a3, b1]);
|
assert_eq!(bob_identity.timeline, vec![a0, a1, a2, a3, b1]);
|
||||||
assert_eq!(bob_identity.revision(&a2), None);
|
assert_eq!(
|
||||||
|
bob_identity.revision(&a2).unwrap().state,
|
||||||
|
State::Redacted(RedactedBy::Author)
|
||||||
|
);
|
||||||
assert_eq!(bob_identity.current, a1);
|
assert_eq!(bob_identity.current, a1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_identity_remove_delegate_concurrent() {
|
fn remove_delegate_concurrent() {
|
||||||
let network = Network::default();
|
let network = Network::default();
|
||||||
let alice = &network.alice;
|
let alice = &network.alice;
|
||||||
let bob = &network.bob;
|
let bob = &network.bob;
|
||||||
|
|
@ -1357,6 +1534,8 @@ mod test {
|
||||||
|
|
||||||
alice_doc.delegate(bob.signer.public_key().into());
|
alice_doc.delegate(bob.signer.public_key().into());
|
||||||
alice_doc.delegate(eve.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 a0 = alice_identity.root;
|
||||||
let a1 = alice_identity // Change description to change traversal order.
|
let a1 = alice_identity // Change description to change traversal order.
|
||||||
.update(
|
.update(
|
||||||
|
|
@ -1367,6 +1546,8 @@ mod test {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
alice_doc.rescind(&eve.signer.public_key().into()).unwrap();
|
alice_doc.rescind(&eve.signer.public_key().into()).unwrap();
|
||||||
|
assert_eq!(alice_doc.delegates.len(), 2);
|
||||||
|
|
||||||
let a2 = alice_identity
|
let a2 = alice_identity
|
||||||
.update(
|
.update(
|
||||||
cob::Title::new("Remove Eve").unwrap(),
|
cob::Title::new("Remove Eve").unwrap(),
|
||||||
|
|
@ -1412,8 +1593,11 @@ mod test {
|
||||||
eve_identity.reload().unwrap();
|
eve_identity.reload().unwrap();
|
||||||
// Now that Eve reloaded, since Bob's vote to remove Eve went through first (b1 < e1),
|
// Now that Eve reloaded, since Bob's vote to remove Eve went through first (b1 < e1),
|
||||||
// her revision is no longer valid.
|
// her revision is no longer valid.
|
||||||
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1]);
|
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1]);
|
||||||
assert_eq!(eve_identity.revision(&e1), None);
|
assert_eq!(
|
||||||
|
eve_identity.revision(&e1).unwrap().state,
|
||||||
|
State::Rejected(RejectedBy::Sibling(a2))
|
||||||
|
);
|
||||||
assert!(!eve_identity.is_delegate(&eve.signer.public_key().into()));
|
assert!(!eve_identity.is_delegate(&eve.signer.public_key().into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1494,10 +1678,9 @@ mod test {
|
||||||
eve_identity.reload().unwrap();
|
eve_identity.reload().unwrap();
|
||||||
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1, e2]);
|
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1, e2]);
|
||||||
|
|
||||||
// Her revision is there, although stale, since another revision was accepted since.
|
// Her revision is there, but rejected, since a sibling revision was already accepted.
|
||||||
// However, it wasn't pruned, even though rejecting an accepted revision is an error.
|
|
||||||
let e2 = eve_identity.revision(&e2).unwrap();
|
let e2 = eve_identity.revision(&e2).unwrap();
|
||||||
assert_eq!(e2.state, State::Stale);
|
assert_eq!(e2.state, State::Rejected(RejectedBy::Sibling(a2)));
|
||||||
assert!(eve_identity.revision(&a2).unwrap().is_accepted());
|
assert!(eve_identity.revision(&a2).unwrap().is_accepted());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1574,7 +1757,10 @@ mod test {
|
||||||
eve_identity.reload().unwrap();
|
eve_identity.reload().unwrap();
|
||||||
|
|
||||||
assert_eq!(eve_identity.timeline, vec![a0, a1, b1, e1, a2]);
|
assert_eq!(eve_identity.timeline, vec![a0, a1, b1, e1, a2]);
|
||||||
assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Stale);
|
assert_eq!(
|
||||||
|
eve_identity.revision(&e1).unwrap().state,
|
||||||
|
State::Rejected(RejectedBy::Sibling(b1))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue