//! LastPass CSV importer. //! //! Pure: takes CSV bytes, returns a vector of `Item` (with freshly-minted //! IDs and timestamps) plus a vector of `ImportWarning` for skipped or //! partially-imported rows. Failed rows never abort the whole import; //! the only fatal error is a missing or malformed header. //! //! Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md //! (D10–D13 + the LastPass field-mapping table). use serde::{Deserialize, Serialize}; use url::Url; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; use crate::item::Item; use crate::item_types::{ItemCore, LoginCore, SecureNoteCore}; /// LastPass column order. The header row must contain these exact column /// names in this exact order. pub const EXPECTED_HEADER: &[&str] = &["url", "username", "password", "totp", "extra", "name", "grouping", "fav"]; /// A row that was skipped, or partially imported with a downgrade /// (e.g., login imported without TOTP). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImportWarning { /// 1-indexed row number in the CSV body (the header is row 0). pub row: usize, /// Title from the row's `name` column, if present and non-empty. pub title: Option, /// Human-readable explanation, suitable for stderr / inline UI. pub message: String, } /// Parse a LastPass CSV export. /// /// Returns the parsed items (with fresh IDs and timestamps) and any /// per-row warnings. The function only fails if the header is missing /// or doesn't match `EXPECTED_HEADER`. pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec, Vec)> { let mut reader = csv::ReaderBuilder::new() .has_headers(true) .flexible(false) .from_reader(csv_bytes); // Validate header. let headers = reader .headers() .map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))? .clone(); if headers.len() != EXPECTED_HEADER.len() || headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want) { return Err(RelicarioError::ImportCsvHeader(format!( "expected `{}`, got `{}`", EXPECTED_HEADER.join(","), headers.iter().collect::>().join(",") ))); } let mut items = Vec::new(); let mut warnings = Vec::new(); for (idx, record) in reader.records().enumerate() { let row_num = idx + 1; let record = match record { Ok(r) => r, Err(e) => { warnings.push(ImportWarning { row: row_num, title: None, message: format!("CSV parse error — skipped: {e}"), }); continue; } }; let (item, warn) = map_row(&record, row_num); if let Some(it) = item { items.push(it); } if let Some(w) = warn { warnings.push(w); } } Ok((items, warnings)) } /// Map a single CSV record. Returns: /// - `(Some(item), None)` for a fully-imported row. /// - `(Some(item), Some(warn))` for a partially-imported row (e.g., /// bad TOTP base32 — login imported without TOTP). /// - `(None, Some(warn))` for a skipped row (missing required field). fn map_row( record: &csv::StringRecord, row: usize, ) -> (Option, Option) { let url = record.get(0).unwrap_or("").trim(); let username = record.get(1).unwrap_or("").trim(); // password and extra are deliberately NOT trimmed: leading/trailing // whitespace is significant inside passwords and free-form notes. let password = record.get(2).unwrap_or(""); let totp_raw = record.get(3).unwrap_or("").trim(); let extra = record.get(4).unwrap_or(""); let name = record.get(5).unwrap_or("").trim(); let group = record.get(6).unwrap_or("").trim(); let fav = record.get(7).unwrap_or("").trim(); if name.is_empty() { return (None, Some(ImportWarning { row, title: None, message: "missing `name` — skipped".into(), })); } // SecureNote marker: LastPass exports notes with `url` set to "http://sn". // The `extra` column carries the body verbatim. if url == "http://sn" { let mut item = Item::new( name.to_string(), ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(extra.to_string()), }), ); item.group = if group.is_empty() { None } else { Some(group.to_string()) }; item.favorite = fav == "1"; return (Some(item), None); } if password.is_empty() { return (None, Some(ImportWarning { row, title: Some(name.to_string()), message: "missing `password` — skipped".into(), })); } let mut warning: Option = None; let parsed_url = if url.is_empty() { None } else { match Url::parse(url) { Ok(u) => Some(u), Err(_) => { // Login still imports — URL becomes None, with a warning. if warning.is_none() { warning = Some(ImportWarning { row, title: Some(name.to_string()), message: format!("invalid URL `{url}` — login imported without URL"), }); } None } } }; let totp = if totp_raw.is_empty() { None } else { match decode_base32_totp(totp_raw) { Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { secret: Zeroizing::new(bytes), algorithm: crate::item_types::TotpAlgorithm::Sha1, digits: 6, period_seconds: 30, kind: crate::item_types::TotpKind::Totp, }), _ => { if warning.is_none() { warning = Some(ImportWarning { row, title: Some(name.to_string()), message: "invalid base32 TOTP secret — login imported without TOTP" .into(), }); } None } } }; let mut item = Item::new( name.to_string(), ItemCore::Login(LoginCore { username: if username.is_empty() { None } else { Some(username.to_string()) }, password: Some(Zeroizing::new(password.to_string())), url: parsed_url, totp, }), ); item.group = if group.is_empty() { None } else { Some(group.to_string()) }; item.favorite = fav == "1"; item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) }; (Some(item), warning) } /// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive, /// padding optional. Returns None if the input contains any non-alphabet /// character (after upper-casing). Used by the LastPass importer. fn decode_base32_totp(secret: &str) -> Option> { const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase(); if upper.is_empty() { return None; } let mut out = Vec::with_capacity(upper.len() * 5 / 8); let mut buffer: u32 = 0; let mut bits: u32 = 0; for ch in upper.bytes() { let idx = ALPHA.iter().position(|&a| a == ch)?; buffer = (buffer << 5) | (idx as u32); bits += 5; if bits >= 8 { bits -= 8; out.push(((buffer >> bits) & 0xFF) as u8); } } Some(out) }