- import_lastpass.rs: note that password and extra are intentionally not trimmed (leading/trailing whitespace is significant for both). - cmd_import_lastpass: document the coupling between the ImportWarning message strings and the CLI summary's "skipped" filter — partial-import warnings (TOTP/URL) must not contain the word "skipped". Comment-only; no behavior change. Catches I1 and M5 from the final code review without taking on the cross-cut WarningKind enum refactor (deferred to a follow-up if it ever ships). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
221 lines
7.6 KiB
Rust
221 lines
7.6 KiB
Rust
//! 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<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;
|
||
}
|
||
};
|
||
|
||
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<Item>, Option<ImportWarning>) {
|
||
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<ImportWarning> = 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<Vec<u8>> {
|
||
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)
|
||
}
|