Files
kestrove-core/crates/kv-import/src/csv.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

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