Skip to content

Commit 75c8783

Browse files
authored
feat: add merge feature (#201)
1 parent 28c7a10 commit 75c8783

File tree

5 files changed

+2148
-11
lines changed

5 files changed

+2148
-11
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ serialization = ["serde", "serde_json", "chrono/serde"]
2525
totp = ["totp-lite", "url", "base32"]
2626
save_kdbx4 = []
2727
challenge_response = ["sha1", "dep:challenge_response"]
28+
_merge = []
2829

2930
default = []
3031

src/db/entry.rs

Lines changed: 200 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
use std::collections::HashMap;
22

3-
use chrono::NaiveDateTime;
43
use secstr::SecStr;
54
use uuid::Uuid;
65

6+
#[cfg(feature = "_merge")]
7+
use crate::db::merge::{MergeError, MergeLog};
8+
#[cfg(all(test, feature = "_merge"))]
9+
use std::{thread, time};
10+
711
use crate::db::{Color, CustomData, Times};
812

913
#[cfg(feature = "totp")]
@@ -41,6 +45,131 @@ impl Entry {
4145
..Default::default()
4246
}
4347
}
48+
49+
#[cfg(feature = "_merge")]
50+
pub(crate) fn merge(&self, other: &Entry) -> Result<(Option<Entry>, MergeLog), MergeError> {
51+
let mut log = MergeLog::default();
52+
53+
let source_last_modification = match other.times.get_last_modification() {
54+
Some(t) => *t,
55+
None => {
56+
log.warnings.push(format!(
57+
"Entry {} did not have a last modification timestamp",
58+
other.uuid
59+
));
60+
Times::epoch()
61+
}
62+
};
63+
let destination_last_modification = match self.times.get_last_modification() {
64+
Some(t) => *t,
65+
None => {
66+
log.warnings.push(format!(
67+
"Entry {} did not have a last modification timestamp",
68+
self.uuid
69+
));
70+
Times::now()
71+
}
72+
};
73+
74+
if destination_last_modification == source_last_modification {
75+
if !self.has_diverged_from(&other) {
76+
// This should never happen.
77+
// This means that an entry was updated without updating the last modification
78+
// timestamp.
79+
return Err(MergeError::EntryModificationTimeNotUpdated(
80+
other.uuid.to_string(),
81+
));
82+
}
83+
return Ok((None, log));
84+
}
85+
86+
let (mut merged_entry, entry_merge_log) = match destination_last_modification > source_last_modification
87+
{
88+
true => self.merge_history(other)?,
89+
false => other.clone().merge_history(&self)?,
90+
};
91+
92+
// The location changed timestamp is handled separately when merging two databases.
93+
if let Some(location_changed_timestamp) = self.times.get_location_changed() {
94+
merged_entry
95+
.times
96+
.set_location_changed(*location_changed_timestamp);
97+
}
98+
99+
return Ok((Some(merged_entry), entry_merge_log));
100+
}
101+
102+
#[cfg(feature = "_merge")]
103+
pub(crate) fn merge_history(&self, other: &Entry) -> Result<(Entry, MergeLog), MergeError> {
104+
let mut log = MergeLog::default();
105+
106+
let mut source_history = match &other.history {
107+
Some(h) => h.clone(),
108+
None => {
109+
log.warnings.push(format!(
110+
"Entry {} from source database had no history.",
111+
self.uuid
112+
));
113+
History::default()
114+
}
115+
};
116+
let mut destination_history = match &self.history {
117+
Some(h) => h.clone(),
118+
None => {
119+
log.warnings.push(format!(
120+
"Entry {} from destination database had no history.",
121+
self.uuid
122+
));
123+
History::default()
124+
}
125+
};
126+
let mut response = self.clone();
127+
128+
if other.has_uncommitted_changes() {
129+
log.warnings.push(format!(
130+
"Entry {} from source database has uncommitted changes.",
131+
self.uuid
132+
));
133+
source_history.add_entry(other.clone());
134+
}
135+
136+
// TODO we should probably check for uncommitted changes in the destination
137+
// database here too for consistency.
138+
139+
let history_merge_log = destination_history.merge_with(&source_history)?;
140+
response.history = Some(destination_history);
141+
142+
Ok((response, log.merge_with(&history_merge_log)))
143+
}
144+
145+
#[cfg(all(test, feature = "_merge"))]
146+
// Convenience function used in unit tests, to make sure that:
147+
// 1. The history gets updated after changing a field
148+
// 2. We wait a second before commiting the changes so that the timestamp is not the same
149+
// as it previously was. This is necessary since the timestamps in the KDBX format
150+
// do not preserve the msecs.
151+
pub(crate) fn set_field_and_commit(&mut self, field_name: &str, field_value: &str) {
152+
self.fields.insert(
153+
field_name.to_string(),
154+
Value::Unprotected(field_value.to_string()),
155+
);
156+
thread::sleep(time::Duration::from_secs(1));
157+
self.update_history();
158+
}
159+
160+
#[cfg(feature = "_merge")]
161+
// Convenience function used in when merging two entries
162+
pub(crate) fn has_diverged_from(&self, other_entry: &Entry) -> bool {
163+
let new_times = Times::default();
164+
165+
let mut self_without_times = self.clone();
166+
self_without_times.times = new_times.clone();
167+
168+
let mut other_without_times = other_entry.clone();
169+
other_without_times.times = new_times.clone();
170+
171+
!self_without_times.eq(&other_without_times)
172+
}
44173
}
45174

46175
impl<'a> Entry {
@@ -148,16 +277,14 @@ impl<'a> Entry {
148277
return true;
149278
}
150279

280+
let new_times = Times::default();
281+
151282
let mut sanitized_entry = self.clone();
152-
sanitized_entry
153-
.times
154-
.set_last_modification(NaiveDateTime::default());
283+
sanitized_entry.times = new_times.clone();
155284
sanitized_entry.history.take();
156285

157286
let mut last_history_entry = history.entries.get(0).unwrap().clone();
158-
last_history_entry
159-
.times
160-
.set_last_modification(NaiveDateTime::default());
287+
last_history_entry.times = new_times.clone();
161288
last_history_entry.history.take();
162289

163290
if sanitized_entry.eq(&last_history_entry) {
@@ -225,6 +352,8 @@ pub struct History {
225352
}
226353
impl History {
227354
pub fn add_entry(&mut self, mut entry: Entry) {
355+
// DISCUSS: should we make sure that the last modification time is not the same
356+
// or older than the entry at the top of the history?
228357
if entry.history.is_some() {
229358
// Remove the history from the new history entry to avoid having
230359
// an exponential number of history entries.
@@ -236,6 +365,70 @@ impl History {
236365
pub fn get_entries(&self) -> &Vec<Entry> {
237366
&self.entries
238367
}
368+
369+
#[cfg(all(test, feature = "_merge"))]
370+
// Determines if the entries of the history are
371+
// ordered by last modification time.
372+
pub(crate) fn is_ordered(&self) -> bool {
373+
let mut last_modification_time: Option<&chrono::NaiveDateTime> = None;
374+
for entry in &self.entries {
375+
if last_modification_time.is_none() {
376+
last_modification_time = entry.times.get_last_modification();
377+
}
378+
379+
let entry_modification_time = entry.times.get_last_modification().unwrap();
380+
// FIXME should we also handle equal modification times??
381+
if last_modification_time.unwrap() < entry_modification_time {
382+
return false;
383+
}
384+
last_modification_time = Some(entry_modification_time);
385+
}
386+
true
387+
}
388+
389+
// Merge both histories together.
390+
#[cfg(feature = "_merge")]
391+
pub(crate) fn merge_with(&mut self, other: &History) -> Result<MergeLog, MergeError> {
392+
let mut log = MergeLog::default();
393+
let mut new_history_entries: HashMap<chrono::NaiveDateTime, Entry> = HashMap::new();
394+
395+
for history_entry in &self.entries {
396+
let modification_time = history_entry.times.get_last_modification().unwrap();
397+
if new_history_entries.contains_key(modification_time) {
398+
return Err(MergeError::DuplicateHistoryEntries(
399+
modification_time.to_string(),
400+
history_entry.uuid.to_string(),
401+
));
402+
}
403+
new_history_entries.insert(modification_time.clone(), history_entry.clone());
404+
}
405+
406+
for history_entry in &other.entries {
407+
let modification_time = history_entry.times.get_last_modification().unwrap();
408+
let existing_history_entry = new_history_entries.get(modification_time);
409+
if let Some(existing_history_entry) = existing_history_entry {
410+
if existing_history_entry.has_diverged_from(&history_entry) {
411+
log.warnings.push(format!(
412+
"History entries for {} have the same modification timestamp but were not the same.",
413+
existing_history_entry.uuid
414+
));
415+
}
416+
} else {
417+
new_history_entries.insert(modification_time.clone(), history_entry.clone());
418+
}
419+
}
420+
421+
let mut all_modification_times: Vec<&chrono::NaiveDateTime> = new_history_entries.keys().collect();
422+
all_modification_times.sort();
423+
all_modification_times.reverse();
424+
let mut new_entries: Vec<Entry> = vec![];
425+
for modification_time in &all_modification_times {
426+
new_entries.push(new_history_entries.get(&modification_time).unwrap().clone());
427+
}
428+
429+
self.entries = new_entries;
430+
Ok(log)
431+
}
239432
}
240433

241434
#[cfg(test)]

0 commit comments

Comments
 (0)