Files
relicario/crates/relicario-core/src/import_lastpass.rs
adlee-was-taken e5d63ab196 refactor(core): extract base32 module, dedupe two RFC 4648 impls
New crates/relicario-core/src/base32.rs hosts encode_rfc4648 +
decode_rfc4648_lenient (case-insensitive, optional padding, whitespace
stripped). Folds inline base32_encode (item.rs:255-275) and
decode_base32_totp (import_lastpass.rs:202-220) into the shared module;
both call sites updated.

- New RelicarioError::InvalidBase32(String) variant for the decoder
  error path
- Module is pub(crate); public API surface unchanged
- Steam alphabet (item_types/totp.rs:13) intentionally separate with
  neighbour comment pointing at crate::base32

Plan B Phase 7 sub-step 1 (DEV-A P2 base32 dedup half).
docs/superpowers/specs/2026-05-04-cli-restructure-design.md.

cargo test --workspace: green
cargo clippy --workspace: silent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 10:46:17 -04:00

199 lines
6.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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 crate::base32::decode_rfc4648_lenient(totp_raw) {
Ok(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)
}