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]>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
//! 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)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user