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,
}
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::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, 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,
30,
None,
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");
}
}