New crate kv-import (pure parsing, no store) and kv-ffi/src/import.rs: - .1pux: accounts -> vaults -> items; Login/Password/Card/Note/Identity mapped, other categories as secure notes; unmapped fields appended to notes; attachments and password history reported. - CSV: 1Password 8 export and generic headers (aliases, BOM, quotes, embedded newlines, extra columns into notes). - Duplicates on (title, username, URL host): skip or add " (imported)". - Encrypted export KVEX v1: Argon2id (>= account KDF minimum) + XChaCha20-Poly1305, authenticated header, padded JSON; restorable through import. - Caps: 100 MB input, 64 MB decompressed export.data, 10k zip entries, 100k items. - FFI: VaultCore.import_preview / import_commit / export_encrypted (unlocked only); items are sealed, seq-stamped and queued for sync like save_item. Co-Authored-By: Claude Opus 5.5 <[email protected]>
111 lines
4.0 KiB
Rust
111 lines
4.0 KiB
Rust
//! Duplicate detection: an imported item duplicates an existing one (or an earlier one in the
|
|
//! same import) when (title, username, primary URL host) match, case-insensitively.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use kv_model::Item;
|
|
|
|
pub const IMPORTED_SUFFIX: &str = " (imported)";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DuplicatePolicy {
|
|
/// Leave the existing item alone and drop the imported one.
|
|
Skip,
|
|
/// Import it anyway, with `" (imported)"` appended to the title.
|
|
AddSuffix,
|
|
}
|
|
|
|
pub type Key = (String, String, String);
|
|
|
|
/// Host of the first URL, lowercased, without a leading `www.`. Empty when there is no usable URL.
|
|
pub fn primary_host(item: &Item) -> String {
|
|
let Some(raw) = item.urls.first().map(|u| u.trim()) else { return String::new() };
|
|
let parsed = url::Url::parse(raw).ok().filter(|u| u.host_str().is_some()).or_else(|| {
|
|
// Bare "example.com/login" from CSVs.
|
|
url::Url::parse(&format!("https://{raw}")).ok()
|
|
});
|
|
let host = parsed.and_then(|u| u.host_str().map(str::to_lowercase)).unwrap_or_else(|| raw.to_lowercase());
|
|
host.strip_prefix("www.").map(str::to_string).unwrap_or(host)
|
|
}
|
|
|
|
pub fn key(item: &Item) -> Key {
|
|
(item.title.trim().to_lowercase(), item.username.as_deref().unwrap_or("").trim().to_lowercase(), primary_host(item))
|
|
}
|
|
|
|
pub struct Plan {
|
|
/// Items to store, in input order.
|
|
pub to_add: Vec<Item>,
|
|
/// Indices into the input of the duplicates (skipped or renamed).
|
|
pub duplicates: Vec<usize>,
|
|
pub skipped: u32,
|
|
pub renamed: u32,
|
|
}
|
|
|
|
/// Decides what to store. `existing` are the keys of the items already in the vault.
|
|
pub fn plan(items: Vec<Item>, existing: impl IntoIterator<Item = Key>, policy: DuplicatePolicy) -> Plan {
|
|
let mut seen: HashSet<Key> = existing.into_iter().collect();
|
|
let mut out = Plan { to_add: Vec::with_capacity(items.len()), duplicates: vec![], skipped: 0, renamed: 0 };
|
|
for (idx, mut item) in items.into_iter().enumerate() {
|
|
let k = key(&item);
|
|
if !seen.contains(&k) {
|
|
seen.insert(k);
|
|
out.to_add.push(item);
|
|
continue;
|
|
}
|
|
out.duplicates.push(idx);
|
|
match policy {
|
|
DuplicatePolicy::Skip => out.skipped += 1,
|
|
DuplicatePolicy::AddSuffix => {
|
|
item.title.push_str(IMPORTED_SUFFIX);
|
|
// Importing the same file twice still yields only one "(imported)" copy per key.
|
|
while !seen.insert(key(&item)) {
|
|
item.title.push_str(IMPORTED_SUFFIX);
|
|
}
|
|
out.renamed += 1;
|
|
out.to_add.push(item);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn login(title: &str, user: &str, url: &str) -> Item {
|
|
let mut i = Item::new_login(title, 0);
|
|
i.username = Some(user.into());
|
|
if !url.is_empty() {
|
|
i.urls = vec![url.into()];
|
|
}
|
|
i
|
|
}
|
|
|
|
#[test]
|
|
fn host_normalization() {
|
|
assert_eq!(primary_host(&login("a", "b", "https://WWW.Example.com/login?x=1")), "example.com");
|
|
assert_eq!(primary_host(&login("a", "b", "example.com/login")), "example.com");
|
|
assert_eq!(primary_host(&login("a", "b", "")), "");
|
|
}
|
|
|
|
#[test]
|
|
fn skip_and_suffix() {
|
|
let existing = [key(&login("GitHub", "me", "https://github.com"))];
|
|
let items = vec![
|
|
login("github", "ME", "https://github.com/login"),
|
|
login("GitHub", "other", "https://github.com"),
|
|
login("Mail", "me", "https://mail.example"),
|
|
login("Mail", "me", "https://mail.example/inbox"),
|
|
];
|
|
let p = plan(items.clone(), existing.clone(), DuplicatePolicy::Skip);
|
|
assert_eq!((p.to_add.len(), p.skipped, p.renamed), (2, 2, 0));
|
|
assert_eq!(p.duplicates, vec![0, 3]);
|
|
|
|
let p = plan(items, existing, DuplicatePolicy::AddSuffix);
|
|
assert_eq!((p.to_add.len(), p.skipped, p.renamed), (4, 0, 2));
|
|
assert_eq!(p.to_add[0].title, "github (imported)");
|
|
assert_eq!(p.to_add[3].title, "Mail (imported)");
|
|
}
|
|
}
|