diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs new file mode 100644 index 0000000..e7a2204 --- /dev/null +++ b/crates/relicario-core/src/import_lastpass.rs @@ -0,0 +1,129 @@ +//! 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}; + +/// 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; + } + }; + + match map_row(&record, row_num) { + Ok(item) => items.push(item), + Err(w) => warnings.push(w), + } + } + + Ok((items, warnings)) +} + +/// Map a single CSV record to either an `Item` or an `ImportWarning`. +/// Column order is fixed by `EXPECTED_HEADER`. +fn map_row(record: &csv::StringRecord, row: usize) -> std::result::Result { + let url = record.get(0).unwrap_or("").trim(); + let username = record.get(1).unwrap_or("").trim(); + let password = record.get(2).unwrap_or(""); + let _totp = record.get(3).unwrap_or(""); // populated in Task 4 + let extra = record.get(4).unwrap_or(""); + let name = record.get(5).unwrap_or("").trim(); + let _group = record.get(6).unwrap_or("").trim(); // populated in Task 3 + let _fav = record.get(7).unwrap_or("").trim(); // populated in Task 3 + + if name.is_empty() { + return Err(ImportWarning { + row, + title: None, + message: "missing `name` — skipped".into(), + }); + } + + if password.is_empty() { + return Err(ImportWarning { + row, + title: Some(name.to_string()), + message: "missing `password` — skipped".into(), + }); + } + + let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; + + 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: None, + }), + ); + item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) }; + Ok(item) +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 1fedf8d..d5bb99f 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -80,3 +80,6 @@ pub mod imgsecret; pub mod backup; pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment}; + +pub mod import_lastpass; +pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; diff --git a/crates/relicario-core/tests/import_lastpass.rs b/crates/relicario-core/tests/import_lastpass.rs new file mode 100644 index 0000000..7cc14db --- /dev/null +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -0,0 +1,46 @@ +//! LastPass CSV importer — parser coverage. + +use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning}; +use relicario_core::ItemCore; + +const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav"; + +#[test] +fn single_login_row_round_trips() { + let csv = format!( + "{HEADER}\n\ + https://github.com/login,alice,hunter2,,,GitHub,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items.len(), 1, "one item expected"); + assert!(warnings.is_empty(), "no warnings expected"); + + let item = &items[0]; + assert_eq!(item.title, "GitHub"); + assert!(!item.favorite); + assert!(item.group.is_none()); + match &item.core { + ItemCore::Login(l) => { + assert_eq!(l.username.as_deref(), Some("alice")); + assert_eq!(l.password.as_deref().map(String::as_str), Some("hunter2")); + assert_eq!(l.url.as_ref().map(|u| u.as_str()), Some("https://github.com/login")); + assert!(l.totp.is_none()); + } + other => panic!("expected Login, got {:?}", other), + } +} + +#[test] +fn item_id_is_freshly_minted() { + // Decision D12: title collisions don't dedupe; each row gets a fresh ID. + let csv = format!("{HEADER}\nhttps://x,u,p,,,Same,,\nhttps://x,u,p,,,Same,,"); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items.len(), 2); + assert_ne!(items[0].id, items[1].id, "IDs must be unique even for identical names"); +} + +// Assertion helper used by later tests. +#[allow(dead_code)] +fn first_warning_message(warnings: &[ImportWarning]) -> String { + warnings.first().expect("expected at least one warning").message.clone() +}