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:
129
crates/relicario-core/src/import_lastpass.rs
Normal file
129
crates/relicario-core/src/import_lastpass.rs
Normal 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
|
||||||
|
//! (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<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)
|
||||||
|
}
|
||||||
@@ -80,3 +80,6 @@ pub mod imgsecret;
|
|||||||
|
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
||||||
|
|
||||||
|
pub mod import_lastpass;
|
||||||
|
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
|||||||
46
crates/relicario-core/tests/import_lastpass.rs
Normal file
46
crates/relicario-core/tests/import_lastpass.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user