Files
kestrove-core/crates/kv-import/src/dedup.rs
T
samiandClaude Opus 5.5 26664e141b kv-import: 1Password .1pux and CSV import, encrypted export (M5)
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]>
2026-10-17 00:36:21 +09:00

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)");
}
}