feat(core): import_lastpass parser — happy-path Login

Pins the parse_lastpass_csv signature and ImportWarning shape.
A single LastPass row with name/url/username/password round-trips
to a Login item with a freshly-minted ID. Header validation
rejects shape mismatches with a clear message.

TOTP, grouping, fav, SecureNote rows, and error paths land in
Tasks 3-6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-29 22:52:20 -04:00
parent 768f0d39a5
commit 9ee876cc4b
3 changed files with 178 additions and 0 deletions

View File

@@ -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
//! (D10D13 + 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<String>,
/// 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<Item>, Vec<ImportWarning>)> {
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::<Vec<_>>().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<Item, ImportWarning> {
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)
}

View File

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