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,185 @@
|
||||
//! CSV import: 1Password's CSV export (`Title,Url,Username,Password,OTPAuth,Favorite,Archived,Tags,Notes`)
|
||||
//! and any CSV whose header names columns like `title,url,username,password,otp,notes`
|
||||
//! (case-insensitive; see [`column_for`]). Columns with no field of their own go into the notes.
|
||||
//!
|
||||
//! RFC 4180 parsing: quoted fields, `""` escapes, embedded newlines, CRLF or LF, a UTF-8 BOM.
|
||||
|
||||
use kv_model::ItemKind;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{malformed, new_item, set_opt, Extras, ImportError, Limits, Parsed, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Column {
|
||||
Title,
|
||||
Url,
|
||||
Username,
|
||||
Password,
|
||||
Otp,
|
||||
Notes,
|
||||
Favorite,
|
||||
Tags,
|
||||
Archived,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Header aliases (lowercased, non-alphanumerics removed). Covers 1Password 7/8, Bitwarden, Chrome.
|
||||
fn column_for(header: &str) -> Column {
|
||||
let h: String = header.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
||||
match h.as_str() {
|
||||
"title" | "name" => Column::Title,
|
||||
"url" | "urls" | "website" | "loginuri" | "loginurl" | "uri" => Column::Url,
|
||||
"username" | "loginusername" | "user" | "login" => Column::Username,
|
||||
"password" | "loginpassword" => Column::Password,
|
||||
"otp" | "otpauth" | "totp" | "logintotp" | "onetimepassword" => Column::Otp,
|
||||
"notes" | "note" | "notesplain" | "comments" => Column::Notes,
|
||||
"favorite" | "favourite" | "fav" => Column::Favorite,
|
||||
"tags" | "folder" => Column::Tags,
|
||||
"archived" => Column::Archived,
|
||||
_ => Column::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed CSV whose cells are wiped on drop.
|
||||
pub struct Records(pub Vec<Vec<String>>);
|
||||
|
||||
impl Drop for Records {
|
||||
fn drop(&mut self) {
|
||||
for row in &mut self.0 {
|
||||
row.iter_mut().for_each(Zeroize::zeroize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 4180 reader. Blank lines are skipped. An unterminated quote is an error.
|
||||
pub fn read_records(text: &str) -> Result<Records> {
|
||||
let mut rows = Records(vec![]);
|
||||
let mut row: Vec<String> = vec![];
|
||||
let mut field = String::new();
|
||||
let mut in_quotes = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if in_quotes {
|
||||
match c {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
field.push('"');
|
||||
}
|
||||
'"' => in_quotes = false,
|
||||
_ => field.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if field.is_empty() => in_quotes = true,
|
||||
',' => row.push(std::mem::take(&mut field)),
|
||||
'\r' if chars.peek() == Some(&'\n') => {}
|
||||
'\n' | '\r' => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
push_row(&mut rows.0, std::mem::take(&mut row));
|
||||
}
|
||||
_ => field.push(c),
|
||||
}
|
||||
}
|
||||
if in_quotes {
|
||||
field.zeroize();
|
||||
row.iter_mut().for_each(Zeroize::zeroize);
|
||||
return Err(malformed("CSV has an unterminated quoted field"));
|
||||
}
|
||||
if !field.is_empty() || !row.is_empty() {
|
||||
row.push(field);
|
||||
push_row(&mut rows.0, row);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn push_row(rows: &mut Vec<Vec<String>>, mut row: Vec<String>) {
|
||||
if row.iter().all(|f| f.trim().is_empty()) {
|
||||
row.iter_mut().for_each(Zeroize::zeroize);
|
||||
} else {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
fn truthy(s: &str) -> bool {
|
||||
matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "y" | "x")
|
||||
}
|
||||
|
||||
pub fn parse(bytes: &[u8], now: i64, limits: &Limits) -> Result<Parsed> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| malformed("CSV is not UTF-8"))?;
|
||||
let records = read_records(text)?;
|
||||
let Some((header, rows)) = records.0.split_first() else { return Err(malformed("CSV is empty")) };
|
||||
let columns: Vec<Column> = header.iter().map(|h| column_for(h)).collect();
|
||||
let has = |c: Column| columns.contains(&c);
|
||||
if !(has(Column::Title) || has(Column::Url))
|
||||
|| !(has(Column::Password) || has(Column::Username) || has(Column::Notes))
|
||||
{
|
||||
return Err(ImportError::Unsupported(
|
||||
"CSV header must name a title or url column and a username, password or notes column".into(),
|
||||
));
|
||||
}
|
||||
limits.check_items(rows.len())?;
|
||||
|
||||
let mut out = Parsed::default();
|
||||
let mut archived = 0u32;
|
||||
for row in rows {
|
||||
let cell = |c: Column| columns.iter().position(|x| *x == c).and_then(|i| row.get(i)).map_or("", |s| s.as_str());
|
||||
let mut item = new_item(ItemKind::Login, cell(Column::Title), now);
|
||||
if cell(Column::Title).trim().is_empty() {
|
||||
if let Some(host) =
|
||||
url::Url::parse(cell(Column::Url).trim()).ok().and_then(|u| u.host_str().map(String::from))
|
||||
{
|
||||
item.title = host;
|
||||
}
|
||||
}
|
||||
set_opt(&mut item.username, cell(Column::Username));
|
||||
set_opt(&mut item.password, cell(Column::Password));
|
||||
set_opt(&mut item.totp, cell(Column::Otp).trim());
|
||||
set_opt(&mut item.notes, cell(Column::Notes));
|
||||
item.urls = cell(Column::Url).lines().map(str::trim).filter(|u| !u.is_empty()).map(String::from).collect();
|
||||
item.favorite = truthy(cell(Column::Favorite));
|
||||
item.tags =
|
||||
cell(Column::Tags).split([',', ';']).map(str::trim).filter(|t| !t.is_empty()).map(String::from).collect();
|
||||
if truthy(cell(Column::Archived)) {
|
||||
item.tags.push("Archived".into());
|
||||
archived += 1;
|
||||
}
|
||||
let mut extras = Extras::default();
|
||||
for (i, value) in row.iter().enumerate() {
|
||||
match columns.get(i) {
|
||||
Some(Column::Other) => extras.push(&header[i], value),
|
||||
Some(_) => {}
|
||||
None => extras.push(&format!("Column {}", i + 1), value),
|
||||
}
|
||||
}
|
||||
extras.append_to(&mut item, "— Imported fields —");
|
||||
out.items.push(item);
|
||||
}
|
||||
if archived > 0 {
|
||||
out.warnings.push(format!("{archived} archived item(s) were imported with the tag \"Archived\""));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rfc4180() {
|
||||
let r = read_records("a,\"b,c\",\"d \"\"q\"\"\"\r\n\r\n\"multi\nline\",,x\n").unwrap();
|
||||
assert_eq!(r.0, vec![vec!["a", "b,c", "d \"q\""], vec!["multi\nline", "", "x"]]);
|
||||
let r = read_records("a,b").unwrap();
|
||||
assert_eq!(r.0, vec![vec!["a", "b"]]);
|
||||
assert!(read_records("a,\"open").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_aliases() {
|
||||
assert_eq!(column_for("Login_URI"), Column::Url);
|
||||
assert_eq!(column_for("OTPAuth"), Column::Otp);
|
||||
assert_eq!(column_for(" Title "), Column::Title);
|
||||
assert_eq!(column_for("Security question"), Column::Other);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user