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]>
186 lines
6.7 KiB
Rust
186 lines
6.7 KiB
Rust
//! 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);
|
|
}
|
|
}
|