//! 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>); 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 { let mut rows = Records(vec![]); let mut row: Vec = 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>, mut row: Vec) { 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 { 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 = 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); } }