use crate::Result;
use crate::sync::merge::{Conflict, ConflictKind};
use crate::vault::Vault;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
KeepLocal,
TakeIncoming,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Adjudicated {
KeptLocal,
TookIncomingEntry,
DeletionApplied,
Restored,
TookIncomingOrdering,
}
pub fn adjudicate(
vault: &mut Vault,
conflict: &Conflict,
decision: Decision,
) -> Result<Adjudicated> {
match &conflict.kind {
ConflictKind::EntryMismatch { incoming, .. } => match decision {
Decision::KeepLocal => Ok(Adjudicated::KeptLocal),
Decision::TakeIncoming => {
vault.replace(conflict.entry_id, incoming.duplicate()?)?;
Ok(Adjudicated::TookIncomingEntry)
}
},
ConflictKind::OrderingMismatch { incoming, .. } => match decision {
Decision::KeepLocal => Ok(Adjudicated::KeptLocal),
Decision::TakeIncoming => {
let group_id = conflict
.group_id()
.ok_or(crate::Error::VaultGroupNotFound)?;
let group = vault
.group_mut(&group_id)
.ok_or(crate::Error::VaultGroupNotFound)?;
*group = incoming.as_ref().clone();
Ok(Adjudicated::TookIncomingOrdering)
}
},
ConflictKind::DeletionConflict {
local, incoming, ..
} => match (decision, local) {
(Decision::KeepLocal, _) => Ok(Adjudicated::KeptLocal),
(Decision::TakeIncoming, None) => {
vault.remove(&conflict.entry_id, incoming.deleted_at);
vault.record_tombstone(incoming.as_ref().clone());
Ok(Adjudicated::DeletionApplied)
}
(Decision::TakeIncoming, Some(entry)) => {
vault.insert_with_id(conflict.entry_id, entry.duplicate()?)?;
vault.forget_tombstone(&conflict.entry_id);
Ok(Adjudicated::Restored)
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::otp::{Algorithm, Digits, OtpSecret};
use crate::sync::{MergedRecord, RecordSet, merge_into};
use crate::vault::{Entry, EntryId, EntryKind, Group, GroupId, Timestamp, Tombstone};
fn sample_entry(name: &str, updated_at: u64) -> Entry {
Entry::new(
name.into(),
Some("Example".into()),
"user@example.com".into(),
OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("valid Base32"),
Algorithm::Sha1,
Digits::SIX,
EntryKind::Totp { period: 30 },
String::new(),
Timestamp::from(1_700_000_000_u64),
Timestamp::from(updated_at),
)
.expect("the fields are valid")
}
fn entry_id(seed: u8) -> EntryId {
EntryId::from_bytes([seed; 16])
}
fn vault_with(id: EntryId, name: &str, updated_at: u64) -> Vault {
let mut vault = Vault::new();
vault
.insert_with_id(id, sample_entry(name, updated_at))
.expect("the vault is empty");
vault
}
fn vault_that_deleted(id: EntryId, deleted_at: u64) -> Vault {
let mut vault = vault_with(id, "deleted", deleted_at);
vault.remove(&id, Timestamp::from(deleted_at));
vault
}
fn mismatch(id: EntryId, local: &Entry, incoming: &Entry) -> Conflict {
Conflict {
entry_id: id,
kind: ConflictKind::EntryMismatch {
local: Box::new(local.duplicate().expect("the entry can be copied")),
incoming: Box::new(incoming.duplicate().expect("the entry can be copied")),
},
}
}
fn peer_entry_against_local_tombstone(
id: EntryId,
incoming: &Entry,
deleted_at: u64,
) -> Conflict {
Conflict {
entry_id: id,
kind: ConflictKind::DeletionConflict {
local: Some(Box::new(
incoming.duplicate().expect("the entry can be copied"),
)),
incoming: Box::new(Tombstone {
entry_id: id,
deleted_at: Timestamp::from(deleted_at),
deleted_by: None,
}),
local_is_newer: incoming.updated_at > Timestamp::from(deleted_at),
},
}
}
fn peer_tombstone_against_local_entry(id: EntryId, deleted_at: u64) -> Conflict {
Conflict {
entry_id: id,
kind: ConflictKind::DeletionConflict {
local: None,
incoming: Box::new(Tombstone {
entry_id: id,
deleted_at: Timestamp::from(deleted_at),
deleted_by: None,
}),
local_is_newer: true,
},
}
}
fn merge_peer_entry(
vault: &mut Vault,
id: EntryId,
entry: &Entry,
) -> crate::sync::MergeOutcome {
let mut records = RecordSet::new();
records.insert(MergedRecord::Entry {
id,
entry: Box::new(entry.duplicate().expect("the entry can be copied")),
});
merge_into(vault, &records).expect("the merge only copies entries")
}
fn merge_peer_tombstone(vault: &mut Vault, tombstone: &Tombstone) -> crate::sync::MergeOutcome {
let mut records = RecordSet::new();
records.insert(MergedRecord::Tombstone(Box::new(tombstone.clone())));
merge_into(vault, &records).expect("a tombstone holds no key material to copy")
}
#[test]
fn taking_the_peer_version_replaces_the_local_entry() {
let id = entry_id(1);
let local = sample_entry("local", 1_700_000_000);
let peer = sample_entry("peer", 1_700_000_100);
let mut vault = vault_with(id, "local", 1_700_000_000);
let outcome = adjudicate(
&mut vault,
&mismatch(id, &local, &peer),
Decision::TakeIncoming,
)
.expect("the entry is present");
assert_eq!(outcome, Adjudicated::TookIncomingEntry);
assert_eq!(vault.len(), 1, "replacing must not add an entry");
let stored = vault.get(&id).expect("the entry is still there");
assert_eq!(stored.name, "peer");
assert_eq!(
stored.updated_at, peer.updated_at,
"the peer's content is adopted as it is, timestamps included"
);
let again = merge_peer_entry(&mut vault, id, &peer);
assert!(
again.conflicts.is_empty() && again.unchanged == 1,
"the decision must close the conflict: {again:?}"
);
}
#[test]
fn keeping_the_local_version_writes_nothing() {
let id = entry_id(2);
let local = sample_entry("local", 1_700_000_000);
let peer = sample_entry("peer", 1_700_000_100);
let mut vault = vault_with(id, "local", 1_700_000_000);
let outcome = adjudicate(
&mut vault,
&mismatch(id, &local, &peer),
Decision::KeepLocal,
)
.expect("keeping the local version cannot fail");
assert_eq!(outcome, Adjudicated::KeptLocal);
assert_eq!(vault.get(&id).expect("the entry stays").name, "local");
}
#[test]
fn a_peer_deletion_is_applied_with_the_peers_own_tombstone() {
let id = entry_id(3);
let mut vault = vault_with(id, "local", 1_700_000_200);
let outcome = adjudicate(
&mut vault,
&peer_tombstone_against_local_entry(id, 1_700_000_100),
Decision::TakeIncoming,
)
.expect("the entry is present");
assert_eq!(outcome, Adjudicated::DeletionApplied);
assert_eq!(vault.len(), 0, "the entry must be gone");
let tombstone = vault
.get_tombstone(&id)
.cloned()
.expect("the deletion is recorded");
assert_eq!(tombstone.deleted_at, Timestamp::from(1_700_000_100_u64));
assert_eq!(
vault.tombstone_count(),
1,
"exactly one tombstone: the entry's own deletion must not be recorded twice"
);
let again = merge_peer_tombstone(&mut vault, &tombstone);
assert!(
again.conflicts.is_empty() && again.unchanged == 1,
"the decision must close the conflict: {again:?}"
);
}
#[test]
fn keeping_the_entry_overrules_a_peer_deletion() {
let id = entry_id(4);
let mut vault = vault_with(id, "local", 1_700_000_200);
let outcome = adjudicate(
&mut vault,
&peer_tombstone_against_local_entry(id, 1_700_000_100),
Decision::KeepLocal,
)
.expect("keeping the local version cannot fail");
assert_eq!(outcome, Adjudicated::KeptLocal);
assert_eq!(vault.len(), 1, "the entry stays");
assert_eq!(
vault.tombstone_count(),
0,
"the peer's tombstone must not be recorded: it was not accepted"
);
}
#[test]
fn the_peer_entry_restores_an_entry_deleted_here() {
let id = entry_id(5);
let peer = sample_entry("peer", 1_700_000_300);
let mut vault = vault_that_deleted(id, 1_700_000_100);
let outcome = adjudicate(
&mut vault,
&peer_entry_against_local_tombstone(id, &peer, 1_700_000_100),
Decision::TakeIncoming,
)
.expect("nothing is copied from the vault here");
assert_eq!(outcome, Adjudicated::Restored);
assert_eq!(vault.get(&id).expect("the entry is back").name, "peer");
assert_eq!(
vault.tombstone_count(),
0,
"a tombstone next to a live entry would be sent on its own and delete the entry again"
);
let again = merge_peer_entry(&mut vault, id, &peer);
assert!(
again.conflicts.is_empty() && again.unchanged == 1,
"the decision must close the conflict: {again:?}"
);
}
#[test]
fn confirming_a_deletion_here_drops_the_peer_entry() {
let id = entry_id(6);
let peer = sample_entry("peer", 1_700_000_300);
let mut vault = vault_that_deleted(id, 1_700_000_100);
let outcome = adjudicate(
&mut vault,
&peer_entry_against_local_tombstone(id, &peer, 1_700_000_100),
Decision::KeepLocal,
)
.expect("keeping the local version cannot fail");
assert_eq!(outcome, Adjudicated::KeptLocal);
assert!(vault.get(&id).is_none(), "the entry stays deleted");
assert!(
vault.get_tombstone(&id).is_some(),
"the deletion is still recorded, so it keeps travelling"
);
}
#[test]
fn a_decision_for_an_entry_that_no_longer_exists_is_refused() {
let id = entry_id(7);
let local = sample_entry("local", 1_700_000_000);
let peer = sample_entry("peer", 1_700_000_100);
let mut vault = Vault::new();
let result = adjudicate(
&mut vault,
&mismatch(id, &local, &peer),
Decision::TakeIncoming,
);
assert!(
matches!(result, Err(crate::Error::VaultEntryNotFound)),
"actual {result:?}"
);
assert_eq!(vault.len(), 0, "a refused decision must write nothing");
}
fn vault_with_ordering(ids: &[EntryId]) -> Vault {
let mut vault = Vault::new();
for (index, id) in ids.iter().enumerate() {
vault
.insert_with_id(*id, sample_entry(&format!("entry {index}"), 1_700_000_000))
.expect("the vault is empty");
}
vault
.set_ordering(&GroupId::TOP, ids.to_vec())
.expect("the identifiers exist");
vault
}
fn incoming_top_group(entry_ids: Vec<EntryId>) -> MergedRecord {
MergedRecord::Group {
id: GroupId::TOP,
group: Box::new(Group::top(entry_ids).expect("the list is valid")),
}
}
#[test]
fn a_group_only_the_peer_has_is_added() {
let mut vault = Vault::new();
let work = GroupId::generate().expect("OS randomness available");
let outcome = merge_into(
&mut vault,
&[MergedRecord::Group {
id: work,
group: Box::new(
Group::new("Work".to_owned(), 0, Vec::new()).expect("the group is valid"),
),
}],
)
.expect("merging a group cannot fail");
assert_eq!(outcome.groups_added, 1);
assert!(outcome.conflicts.is_empty());
assert_eq!(
vault.group(&work).map(|group| group.name.as_str()),
Some("Work")
);
}
#[test]
fn orderings_that_differ_only_in_their_entries_agree() {
let shared = entry_id(20);
let mut vault = vault_with_ordering(&[shared, entry_id(21)]);
let outcome = merge_into(&mut vault, &[incoming_top_group(vec![shared])])
.expect("merging cannot fail");
assert!(
outcome.conflicts.is_empty(),
"the peer lists a subset of what this device has, in the same relative order"
);
assert_eq!(outcome.unchanged, 1);
}
#[test]
fn arriving_entries_are_placed_in_the_peer_order() {
let first = entry_id(30);
let second = entry_id(31);
let mut vault = Vault::new();
let outcome = merge_into(
&mut vault,
&[
MergedRecord::Entry {
id: first,
entry: Box::new(sample_entry("first", 1_700_000_000)),
},
MergedRecord::Entry {
id: second,
entry: Box::new(sample_entry("second", 1_700_000_000)),
},
incoming_top_group(vec![second, first]),
],
)
.expect("merging cannot fail");
assert_eq!(outcome.added, 2);
assert!(outcome.conflicts.is_empty(), "actual {outcome:?}");
assert_eq!(
vault.ordering(&GroupId::TOP),
Some([second, first].as_slice()),
"the arrangement is the peer's"
);
}
#[test]
fn a_disagreeing_ordering_is_adjudicated_as_a_whole() {
let first = entry_id(40);
let second = entry_id(41);
let mut vault = vault_with_ordering(&[first, second]);
let outcome = merge_into(&mut vault, &[incoming_top_group(vec![second, first])])
.expect("merging cannot fail");
let conflict = outcome
.conflicts
.first()
.expect("the two arrangements disagree");
assert_eq!(conflict.group_id(), Some(GroupId::TOP));
assert!(
matches!(conflict.kind, ConflictKind::OrderingMismatch { .. }),
"actual {:?}",
conflict.kind
);
assert_eq!(
vault.ordering(&GroupId::TOP),
Some([first, second].as_slice()),
"a conflict writes nothing until it is adjudicated"
);
assert_eq!(
adjudicate(&mut vault, conflict, Decision::KeepLocal).expect("keeping cannot fail"),
Adjudicated::KeptLocal
);
assert_eq!(
vault.ordering(&GroupId::TOP),
Some([first, second].as_slice())
);
assert_eq!(
adjudicate(&mut vault, conflict, Decision::TakeIncoming)
.expect("taking the peer's arrangement cannot fail"),
Adjudicated::TookIncomingOrdering
);
assert_eq!(
vault.ordering(&GroupId::TOP),
Some([second, first].as_slice()),
"the whole arrangement was replaced"
);
}
#[test]
fn merging_the_same_group_twice_changes_nothing() {
let first = entry_id(50);
let second = entry_id(51);
let mut vault = vault_with_ordering(&[first, second]);
let records = [incoming_top_group(vec![first, second])];
let outcome = merge_into(&mut vault, &records).expect("merging cannot fail");
assert_eq!(outcome.unchanged, 1);
assert!(outcome.made_no_change());
let again = merge_into(&mut vault, &records).expect("merging cannot fail");
assert!(
again.made_no_change(),
"the second merge reports changes: {again:?}"
);
}
}