use std::collections::BTreeMap;
use std::collections::HashMap;
use crate::Result;
use crate::import::{ImportReport, ImportedEntry};
use crate::vault::{Entry, EntryId, GroupId, Vault};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Difference {
Name,
Issuer,
Account,
Note,
Algorithm,
Digits,
Kind,
}
#[derive(Debug, Clone)]
pub struct Match {
pub position: usize,
pub existing: Vec<EntryId>,
pub identical: bool,
pub differences: Vec<Difference>,
}
impl Match {
#[must_use]
pub fn is_new(&self) -> bool {
self.existing.is_empty()
}
}
#[must_use]
pub fn compare(report: &ImportReport, vault: &Vault) -> Vec<Match> {
report
.entries
.iter()
.map(|imported| match_entry(imported, vault))
.collect()
}
fn match_entry(imported: &ImportedEntry, vault: &Vault) -> Match {
let mut existing = Vec::new();
for (id, entry) in vault.iter() {
if entry.secret == imported.entry.secret {
existing.push(*id);
}
}
let identical = existing.iter().any(|id| {
vault
.get(id)
.is_some_and(|entry| same_content(entry, &imported.entry))
});
let differences = existing
.first()
.and_then(|id| vault.get(id))
.map_or_else(Vec::new, |entry| differences(entry, &imported.entry));
Match {
position: imported.position,
existing,
identical,
differences,
}
}
#[must_use]
pub fn same_content(left: &Entry, right: &Entry) -> bool {
left.name == right.name
&& left.issuer == right.issuer
&& left.account == right.account
&& left.note == right.note
&& left.algorithm == right.algorithm
&& left.digits == right.digits
&& left.kind == right.kind
&& left.secret == right.secret
}
fn differences(existing: &Entry, imported: &Entry) -> Vec<Difference> {
let mut found = Vec::new();
if existing.name != imported.name {
found.push(Difference::Name);
}
if existing.issuer != imported.issuer {
found.push(Difference::Issuer);
}
if existing.account != imported.account {
found.push(Difference::Account);
}
if existing.note != imported.note {
found.push(Difference::Note);
}
if existing.algorithm != imported.algorithm {
found.push(Difference::Algorithm);
}
if existing.digits != imported.digits {
found.push(Difference::Digits);
}
if existing.kind != imported.kind {
found.push(Difference::Kind);
}
found
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Choice {
Add,
Skip,
Replace(EntryId),
KeepBoth,
}
#[derive(Debug, Default, Clone)]
pub struct Applied {
pub added: Vec<EntryId>,
pub replaced: Vec<EntryId>,
pub skipped: usize,
pub groups_created: Vec<GroupId>,
pub groups_existing: Vec<GroupId>,
}
impl Applied {
#[must_use]
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.replaced.is_empty() && self.skipped == 0
}
}
pub fn apply(
vault: &mut Vault,
report: &ImportReport,
choices: &HashMap<usize, Choice>,
) -> Result<Applied> {
let mut applied = Applied::default();
for group in &report.groups {
if vault.group(&group.id).is_some() {
applied.groups_existing.push(group.id);
continue;
}
vault.create_group_with_id(group.id, group.name.clone())?;
applied.groups_created.push(group.id);
}
let mut arrivals: Vec<Arrival> = Vec::new();
for imported in &report.entries {
let position = imported.position;
let choice = choices.get(&position).copied().unwrap_or_else(|| {
if same_content_of_vault(vault, imported) {
Choice::Skip
} else {
Choice::Add
}
});
match choice {
Choice::Skip => applied.skipped += 1,
Choice::Replace(id) => {
vault.replace(id, imported.entry.duplicate()?)?;
applied.replaced.push(id);
}
Choice::Add | Choice::KeepBoth => {
let id = vault.insert(imported.entry.duplicate()?)?;
applied.added.push(id);
arrivals.push(Arrival {
id,
group: target_group(vault, imported),
favorite: imported.favorite,
position,
});
}
}
}
place(vault, &order_arrivals(&arrivals))?;
Ok(applied)
}
struct Arrival {
id: EntryId,
group: GroupId,
favorite: bool,
position: usize,
}
fn order_arrivals(arrivals: &[Arrival]) -> BTreeMap<GroupId, Vec<EntryId>> {
let mut ordered: BTreeMap<GroupId, Vec<(bool, usize, EntryId)>> = BTreeMap::new();
for arrival in arrivals {
ordered.entry(arrival.group).or_default().push((
!arrival.favorite,
arrival.position,
arrival.id,
));
}
ordered
.into_iter()
.map(|(group, mut entries)| {
entries.sort_unstable();
(group, entries.into_iter().map(|(_, _, id)| id).collect())
})
.collect()
}
fn place(vault: &mut Vault, placements: &BTreeMap<GroupId, Vec<EntryId>>) -> Result<()> {
let mut named: Vec<(&GroupId, &Vec<EntryId>)> = placements.iter().collect();
named.sort_by_key(|(group, _)| group.is_top());
for (group, arrivals) in named {
let mut ordering: Vec<EntryId> = vault
.ordering(group)
.unwrap_or_default()
.iter()
.filter(|id| vault.get(id).is_some() && !arrivals.contains(id))
.copied()
.collect();
ordering.extend(arrivals.iter().copied());
vault.set_ordering(group, ordering)?;
}
Ok(())
}
fn target_group(vault: &Vault, imported: &ImportedEntry) -> GroupId {
imported
.groups
.iter()
.find(|group| vault.group(group).is_some())
.copied()
.unwrap_or(GroupId::TOP)
}
fn same_content_of_vault(vault: &Vault, imported: &ImportedEntry) -> bool {
vault
.iter()
.any(|(_, entry)| same_content(entry, &imported.entry))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::{ImportFormat, ImportedEntry};
fn report_of(lines: &str) -> ImportReport {
crate::import::import(lines.as_bytes(), ImportFormat::Text, 1_700_000_000, None)
.expect("the lines are valid UTF-8")
}
fn entry(uri: &str, now: u64) -> Entry {
let parsed = crate::uri::parse(uri).expect("the uri is valid");
crate::vault::entry_from_parsed_uri(parsed, now).expect("the entry is valid")
}
fn vault_with(uri: &str) -> (Vault, EntryId) {
let mut vault = Vault::new();
let id = vault
.insert(entry(uri, 1_600_000_000))
.expect("the vault is empty");
(vault, id)
}
fn imported(position: usize, uri: &str) -> ImportedEntry {
let mut report = report_of(uri);
let mut only = report.entries.remove(0);
only.position = position;
only
}
#[test]
fn an_entry_the_vault_already_holds_is_identical() {
let (vault, id) = vault_with("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let report = report_of("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let matches = compare(&report, &vault);
assert_eq!(matches.len(), 1);
assert!(matches[0].identical, "actual {matches:?}");
assert_eq!(matches[0].existing, vec![id]);
assert!(matches[0].differences.is_empty());
}
#[test]
fn an_entry_with_the_same_secret_but_a_different_name_is_a_question() {
let (vault, id) = vault_with("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let report = report_of("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&digits=8");
let matches = compare(&report, &vault);
assert!(!matches[0].identical);
assert_eq!(
matches[0].existing,
vec![id],
"the secret is what identifies it"
);
assert_eq!(
matches[0].differences,
vec![Difference::Digits],
"the interface has to be able to say what would change"
);
}
#[test]
fn an_entry_nothing_matches_is_new() {
let (vault, _) = vault_with("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let report = report_of("otpauth://totp/Bank:bob?secret=KRSXG5CTMVRXEZLU");
let matches = compare(&report, &vault);
assert!(matches[0].is_new());
assert!(!matches[0].identical);
assert!(matches[0].differences.is_empty());
}
#[test]
fn applying_without_answers_skips_what_is_there_and_adds_what_is_not() {
let (mut vault, existing) =
vault_with("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let report = report_of(
"otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP\n\
otpauth://totp/Bank:bob?secret=KRSXG5CTMVRXEZLU\n",
);
let applied = apply(&mut vault, &report, &HashMap::new()).expect("applying cannot fail");
assert_eq!(applied.skipped, 1);
assert_eq!(applied.added.len(), 1);
assert!(applied.replaced.is_empty());
assert_eq!(vault.len(), 2);
assert!(vault.get(&existing).is_some(), "the existing entry stays");
let ordering: Vec<&Entry> = vault
.ordering(&GroupId::TOP)
.expect("the top group exists")
.iter()
.filter_map(|id| vault.get(id))
.collect();
assert_eq!(ordering.len(), 2);
assert_eq!(ordering[1].account, "bob", "the arrival is appended");
}
#[test]
fn a_group_the_export_names_is_created_and_receives_its_entries() {
let mut vault = Vault::new();
let mut report = report_of("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let group = GroupId::from_bytes([0x5A; GroupId::LEN]);
report.groups.push(crate::import::ImportedGroup {
id: group,
name: "Work".to_owned(),
});
report.entries[0].groups.push(group);
let applied = apply(&mut vault, &report, &HashMap::new()).expect("applying cannot fail");
assert_eq!(applied.groups_created, vec![group]);
assert_eq!(
vault.group(&group).map(|found| found.name.as_str()),
Some("Work")
);
let placed: Vec<EntryId> = vault
.ordering(&group)
.expect("the group has an ordering")
.to_vec();
assert_eq!(placed, applied.added, "the entry is in the group it named");
assert!(
vault
.ordering(&GroupId::TOP)
.expect("the top group exists")
.is_empty(),
"and no longer in the top group"
);
let again = apply(&mut vault, &report, &HashMap::new()).expect("applying cannot fail");
assert_eq!(again.groups_created.len(), 0);
assert_eq!(again.groups_existing, vec![group]);
assert_eq!(again.skipped, 1, "the entry is already there");
assert_eq!(vault.len(), 1);
}
#[test]
fn favourites_are_placed_before_the_rest() {
let mut vault = Vault::new();
let mut report = report_of(
"otpauth://totp/First:a?secret=JBSWY3DPEHPK3PXP\n\
otpauth://totp/Second:b?secret=KRSXG5CTMVRXEZLU\n\
otpauth://totp/Third:c?secret=MFRGGZDFMZTWQ2LK\n",
);
report.entries[2].favorite = true;
let applied = apply(&mut vault, &report, &HashMap::new()).expect("applying cannot fail");
let ordering = vault.ordering(&GroupId::TOP).expect("the top group exists");
assert_eq!(
ordering,
[applied.added[2], applied.added[0], applied.added[1]],
"the favourite comes first, and the rest keep the export order"
);
}
#[test]
fn replacing_keeps_the_identifier_and_the_place() {
let (mut vault, existing) =
vault_with("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let report = report_of("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&digits=8");
let mut choices = HashMap::new();
choices.insert(1_usize, Choice::Replace(existing));
let applied = apply(&mut vault, &report, &choices).expect("applying cannot fail");
assert_eq!(applied.replaced, vec![existing]);
assert!(applied.added.is_empty());
assert_eq!(vault.len(), 1);
assert_eq!(
vault.get(&existing).map(|entry| entry.digits),
Some(crate::otp::Digits::EIGHT),
"the content came from the import"
);
assert_eq!(
vault.ordering(&GroupId::TOP),
Some(vec![existing]),
"and the row did not move"
);
}
#[test]
fn keeping_both_adds_a_second_entry_with_the_same_secret() {
let (mut vault, existing) =
vault_with("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let report = report_of("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&digits=8");
let mut choices = HashMap::new();
choices.insert(1_usize, Choice::KeepBoth);
let applied = apply(&mut vault, &report, &choices).expect("applying cannot fail");
assert_eq!(applied.added.len(), 1);
assert_eq!(vault.len(), 2);
assert!(vault.get(&existing).is_some());
}
#[test]
fn an_answer_for_an_entry_the_vault_no_longer_holds_is_refused() {
let mut vault = Vault::new();
let report = report_of("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let missing = EntryId::from_bytes([0x11; EntryId::LEN]);
let mut choices = HashMap::new();
choices.insert(1_usize, Choice::Replace(missing));
let result = apply(&mut vault, &report, &choices);
assert!(
matches!(result, Err(crate::Error::VaultEntryNotFound)),
"actual {result:?}"
);
assert_eq!(vault.len(), 0, "a refused answer writes nothing");
}
#[test]
fn the_comparison_reports_every_field_that_differs() {
let existing = entry("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP", 0);
let other = entry(
"otpauth://hotp/Other:bob?secret=JBSWY3DPEHPK3PXP&digits=8&counter=9",
0,
);
let mut found = differences(&existing, &other);
found.sort_by_key(|difference| format!("{difference:?}"));
assert!(found.contains(&Difference::Name));
assert!(found.contains(&Difference::Issuer));
assert!(found.contains(&Difference::Account));
assert!(found.contains(&Difference::Digits));
assert!(found.contains(&Difference::Kind));
assert!(!found.contains(&Difference::Note), "both notes are empty");
}
#[test]
fn a_position_from_the_export_is_what_an_answer_is_keyed_by() {
let (vault, _) = vault_with("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
let mut report = report_of("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP");
report.entries[0].position = 7;
let only = imported(7, "otpauth://totp/Other:bob?secret=KRSXG5CTMVRXEZLU");
assert_eq!(only.position, 7);
let matches = compare(&report, &vault);
assert_eq!(matches[0].position, 7);
}
}