diff --git a/docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md b/docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md new file mode 100644 index 0000000..58c9c17 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md @@ -0,0 +1,2559 @@ +# relicario LastPass Importer — Implementation Plan (v0.3.0 — Plan 3B) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a LastPass CSV importer with full CLI / extension parity. `relicario import lastpass ` writes new items + manifest in a single CLI git commit; the vault-tab Import panel parses, previews, and bulk-adds via the SW. Closes the second-half of v0.3.0's "self-host without losing your existing data" story. + +**Architecture:** A pure parser in `relicario-core::import_lastpass` (CSV bytes in → `Vec` + `Vec` out, no IO). The CLI shells out to git for the single-commit semantics; the extension SW does per-item `writeFile` calls (one commit per item, manifest commit last) — same pattern as the existing `add_item` handler. Items always get freshly-minted IDs so re-running the same CSV creates duplicates rather than corrupting state. + +**Tech Stack:** Rust (`csv` crate, existing `data_encoding`/`base32` for TOTP secrets); TypeScript (extension vault tab + service-worker handlers); vitest for SW + UI tests. + +**Spec:** `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` — D10–D13, the LastPass field-mapping table, and the import data flow diagram. + +**Sibling plan:** Plan 3A (`docs/superpowers/plans/2026-04-27-relicario-backup-restore.md`) shipped backup & restore. v0.3.0 ships when both 3A and 3B are merged and the pre-v0.3.0 audit checklist (`docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md`) is walked. + +--- + +## Scope decisions (locked in by this plan) + +These are calls made on top of the spec — record them so the executor doesn't re-litigate them mid-task. + +- **CLI is single-commit, extension is multi-commit.** D13 ("ONE git commit covering all newly written items + manifest") is achievable from the CLI because `commit_paths` shells out to `git add` once and `git commit` once. The extension's `GitHost.writeFile` does one commit per file as a hard architectural constraint (same as existing `add_item`). For the extension, each item lands in its own commit and the manifest update lands in a final commit. The user-facing summary text is the same in both surfaces; the difference is only visible in the underlying git log. +- **Two SW message types, one panel.** The vault-tab Import panel needs (1) a parse-only round-trip to populate the preview and (2) a separate commit message that takes the (possibly-edited) item list and writes it. This avoids round-tripping the full preview structure twice and matches the spec's preview → confirm flow ("142 logins, 17 notes, 3 skipped — proceed?"). Names: `parse_lastpass_csv` (parse only, no vault state needed) and `import_lastpass_commit` (requires unlock, writes items + manifest). +- **`Item.notes` is preserved verbatim for non-SecureNote rows.** The spec says "`extra` (when `url != http://sn`) → `Item.notes`". LastPass packs structured login metadata (one-time URLs, hint text, etc.) into the same `extra` column for ordinary logins; we preserve it as-is on `Item.notes`. No attempt to parse it further. +- **Import does not touch the trash, devices, or settings.** It is purely additive: new items, manifest update, one git commit. The vault must already be initialized; "import into an empty repo" is a separate flow (`init` + `import`). +- **Preview UI shows aggregate counts only**, not per-row detail. Reasoning: a 1000-row CSV would render an unusable list; the user's decision is "proceed or cancel," not "review each row." Warnings list (skipped rows) is shown after the import completes, not in the preview. + +--- + +## File map + +**Created:** +- `crates/relicario-core/src/import_lastpass.rs` — module with `parse_lastpass_csv`, `ImportWarning`, field-mapping logic. +- `crates/relicario-core/tests/import_lastpass.rs` — parser unit-test coverage. +- `crates/relicario-cli/tests/import_lastpass.rs` — end-to-end via `TestVault`. +- `crates/relicario-cli/tests/fixtures/lastpass-sample.csv` — synthesized sample, ~15 rows, no real credentials. +- `extension/src/vault/components/import-panel.ts` — vault-tab Import UI. +- `extension/src/vault/components/__tests__/import-panel.test.ts` — vitest. +- `extension/src/service-worker/__tests__/import.test.ts` — SW handler unit tests. + +**Modified:** +- `crates/relicario-core/Cargo.toml` — add `csv = "1"`. +- `crates/relicario-core/src/lib.rs` — `pub mod import_lastpass` + re-exports. +- `crates/relicario-core/src/error.rs` — new variants (`ImportCsvHeader`, `ImportCsvFormat`). +- `crates/relicario-cli/src/main.rs` — `Import` clap variant + `cmd_import` / `cmd_import_lastpass` handlers. +- `crates/relicario-cli/tests/common/mod.rs` — (no changes anticipated; existing `TestVault` covers it — flagged here only so executors who hit a missing helper know where to add it). +- `crates/relicario-wasm/src/lib.rs` — `parse_lastpass_csv_json` export. +- `extension/src/shared/messages.ts` — `parse_lastpass_csv`, `import_lastpass_commit` request types. +- `extension/src/service-worker/router/popup-only.ts` — handler arms. +- `extension/src/service-worker/router/__tests__/router.test.ts` — sender matrix for the new types. +- `extension/src/vault/vault.ts` — register the panel + add hash route `#import`. +- `extension/src/popup/components/settings-vault.ts` — add "Import →" button next to "Backup & restore →"; deep-links the vault tab to `#import`. +- `CHANGELOG.md` — `Unreleased` entry. + +--- + +## Pre-flight check + +Run before starting Task 1: + +```bash +cargo build && cargo test +cd extension && pnpm install && pnpm test +``` + +Both must be green. The plan starts from a clean `main` (Plan 3A is already merged). + +--- + +## Task 1: Add CSV dep + error variants + +**Files:** +- Modify: `crates/relicario-core/Cargo.toml` +- Modify: `crates/relicario-core/src/error.rs` + +The parser uses the `csv` crate to handle quoted commas, multi-line `extra`, and unicode robustness. Two new error variants cover header-shape failures and per-row CSV parse errors. + +- [ ] **Step 1: Add the csv dep** + +Append to `crates/relicario-core/Cargo.toml` under `[dependencies]` (after `base64 = "0.22"`): + +```toml +csv = "1" +``` + +- [ ] **Step 2: Add error variants** + +Open `crates/relicario-core/src/error.rs`. Insert these variants after `BackupSchemaMismatch` (around line 52, before `ItemNotFound`): + +```rust + /// CSV header doesn't match the LastPass column layout. + #[error("unrecognized CSV header — expected LastPass export format ({0})")] + ImportCsvHeader(String), + + /// CSV body could not be parsed (mismatched quoting, encoding, etc.). + /// Per-row record errors that the importer recovers from become + /// `ImportWarning` entries — this variant is reserved for failures + /// that abort the whole import. + #[error("CSV parse failed: {0}")] + ImportCsvFormat(String), +``` + +- [ ] **Step 3: Verify compile** + +Run: `cargo check -p relicario-core` +Expected: PASS, no warnings. + +- [ ] **Step 4: Add error test** + +Append to the `#[cfg(test)] mod tests` block in `error.rs`: + +```rust + #[test] + fn import_errors_carry_useful_messages() { + let h = RelicarioError::ImportCsvHeader("missing 'name' column".into()); + assert!(format!("{}", h).contains("LastPass")); + assert!(format!("{}", h).contains("missing 'name'")); + + let f = RelicarioError::ImportCsvFormat("unterminated quote at line 12".into()); + assert!(format!("{}", f).contains("CSV parse failed")); + assert!(format!("{}", f).contains("unterminated quote")); + } +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p relicario-core error::tests` +Expected: PASS (six tests including the new `import_errors_carry_useful_messages`). + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-core/Cargo.toml crates/relicario-core/src/error.rs Cargo.lock +git commit -m "feat(core): add csv dep + import error variants + +Adds csv = \"1\" to relicario-core; introduces +ImportCsvHeader and ImportCsvFormat. Foundation for the +import_lastpass module landing in Task 2." +``` + +--- + +## Task 2: Parser module — happy-path Login row (TDD) + +**Files:** +- Create: `crates/relicario-core/src/import_lastpass.rs` +- Create: `crates/relicario-core/tests/import_lastpass.rs` +- Modify: `crates/relicario-core/src/lib.rs` + +Pin the public API on the smallest case — a single LastPass row with `name`, `url`, `username`, `password`. This locks in the function signature, the `ImportWarning` struct shape, and the column-order expectation. + +- [ ] **Step 1: Write the failing test** + +Create `crates/relicario-core/tests/import_lastpass.rs`: + +```rust +//! 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() +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: FAIL with "module `import_lastpass` not found in crate `relicario_core`" or similar. + +- [ ] **Step 3: Implement minimal parser** + +Create `crates/relicario-core/src/import_lastpass.rs`: + +```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}; + +/// 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) +} +``` + +Then add to `crates/relicario-core/src/lib.rs` (after the `pub mod backup;` block at the bottom): + +```rust +pub mod import_lastpass; +pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: PASS (`single_login_row_round_trips`, `item_id_is_freshly_minted`). + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/src/import_lastpass.rs \ + crates/relicario-core/tests/import_lastpass.rs \ + crates/relicario-core/src/lib.rs +git commit -m "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." +``` + +--- + +## Task 3: Pass-through metadata — group, favorite, multi-line `extra` + +**Files:** +- Modify: `crates/relicario-core/src/import_lastpass.rs` +- Modify: `crates/relicario-core/tests/import_lastpass.rs` + +LastPass's `grouping`, `fav`, and `extra` columns map straightforwardly to relicario fields. `extra` is preserved verbatim for login rows (multi-line content via CSV quoting must round-trip). + +- [ ] **Step 1: Write the failing tests** + +Append to `crates/relicario-core/tests/import_lastpass.rs`: + +```rust +#[test] +fn grouping_maps_to_item_group() { + let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,Finance,"); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(items[0].group.as_deref(), Some("Finance")); +} + +#[test] +fn empty_grouping_yields_none() { + let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,"); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(items[0].group.is_none()); +} + +#[test] +fn fav_one_marks_favorite() { + let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,1"); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(items[0].favorite); +} + +#[test] +fn fav_zero_or_blank_not_favorite() { + let csv = format!( + "{HEADER}\n\ + https://x,u,p,,,Zero,,0\n\ + https://x,u,p,,,Blank,,", + ); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items.len(), 2); + assert!(!items[0].favorite); + assert!(!items[1].favorite); +} + +#[test] +fn extra_becomes_notes_for_login() { + let csv = format!("{HEADER}\nhttps://x,u,p,,a hint,Bank,,"); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items[0].notes.as_deref(), Some("a hint")); +} + +#[test] +fn multiline_extra_round_trips_via_quoting() { + // CSV double-quotes escape embedded newlines. + let csv = format!( + "{HEADER}\n\ + https://x,u,p,,\"line1\nline2\nline3\",Bank,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty(), "multi-line extra should parse cleanly"); + assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3")); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: FAIL on `grouping_maps_to_item_group`, `fav_one_marks_favorite`, etc. + +- [ ] **Step 3: Wire up the metadata fields** + +Edit `crates/relicario-core/src/import_lastpass.rs`. Replace the `_group` / `_fav` lines and the `Item::new(...)` block in `map_row` with: + +```rust + 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(); + let fav = record.get(7).unwrap_or("").trim(); + + 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.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()) }; + Ok(item) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: PASS — all 8 tests so far green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/src/import_lastpass.rs \ + crates/relicario-core/tests/import_lastpass.rs +git commit -m "feat(core): import_lastpass — group, favorite, notes" +``` + +--- + +## Task 4: TOTP — base32 decode → embedded TotpConfig + +**Files:** +- Modify: `crates/relicario-core/src/import_lastpass.rs` +- Modify: `crates/relicario-core/tests/import_lastpass.rs` + +Per the spec mapping: a non-empty `totp` column carries a base32-encoded TOTP secret (LastPass's standard format). On success, build a SHA1/6/30s/Totp `TotpConfig` and attach it to `LoginCore.totp`. Bad base32 emits a warning; the login still imports without TOTP. + +- [ ] **Step 1: Write the failing tests** + +Append to `crates/relicario-core/tests/import_lastpass.rs`: + +```rust +use relicario_core::item_types::{TotpAlgorithm, TotpKind}; + +#[test] +fn login_with_valid_totp_secret_attaches_config() { + // RFC 4648 base32 of b"12345678901234567890" → "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ". + let csv = format!( + "{HEADER}\n\ + https://github.com/login,alice,hunter2,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,,GitHub,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty()); + match &items[0].core { + ItemCore::Login(l) => { + let totp = l.totp.as_ref().expect("expected TOTP config"); + assert_eq!(totp.algorithm, TotpAlgorithm::Sha1); + assert_eq!(totp.digits, 6); + assert_eq!(totp.period_seconds, 30); + assert_eq!(totp.kind, TotpKind::Totp); + assert_eq!(totp.secret.as_slice(), b"12345678901234567890"); + } + other => panic!("expected Login, got {:?}", other), + } +} + +#[test] +fn login_with_bad_totp_secret_imports_without_totp_and_warns() { + let csv = format!( + "{HEADER}\n\ + https://github.com/login,alice,hunter2,!!!!not-base32!!!!,,GitHub,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items.len(), 1, "login should still import"); + match &items[0].core { + ItemCore::Login(l) => assert!(l.totp.is_none(), "TOTP must be dropped"), + other => panic!("expected Login, got {:?}", other), + } + assert_eq!(warnings.len(), 1); + let w = &warnings[0]; + assert_eq!(w.title.as_deref(), Some("GitHub")); + assert!(w.message.contains("TOTP"), "message: {}", w.message); + assert!(w.message.contains("invalid") || w.message.contains("base32")); +} + +#[test] +fn login_with_lowercase_base32_totp_is_accepted() { + // RFC 4648 is case-insensitive; LastPass exports may use either case. + let csv = format!( + "{HEADER}\n\ + https://x,u,p,gezdgnbvgy3tqojqgezdgnbvgy3tqojq,,Acme,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty(), "lowercase base32 must parse"); + match &items[0].core { + ItemCore::Login(l) => assert!(l.totp.is_some()), + _ => unreachable!(), + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: FAIL on the three new tests (`l.totp` is `None`). + +- [ ] **Step 3: Add the base32 helper + refactor `map_row` to `(Option, Option)`** + +A row can produce both an item AND a warning (e.g., login imports without TOTP because the base32 was bad). The current `Result` shape can't express that — so we change the signature to a tuple. + +Edit `crates/relicario-core/src/import_lastpass.rs`. Add this helper above the (existing or future) `#[cfg(test)]` block, at module bottom: + +```rust +/// 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> { + 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) +} +``` + +Then replace the entire `map_row` function with this final version: + +```rust +/// 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, Option) { + 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_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(), + })); + } + + if password.is_empty() { + return (None, Some(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 warning: Option = 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, + }), + _ => { + 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) +} +``` + +Update the caller (`parse_lastpass_csv` body) to consume the new tuple. Replace the `match map_row(...)` block with: + +```rust + let (item, warn) = map_row(&record, row_num); + if let Some(it) = item { items.push(it); } + if let Some(w) = warn { warnings.push(w); } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: PASS — all 11 tests green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/src/import_lastpass.rs \ + crates/relicario-core/tests/import_lastpass.rs +git commit -m "feat(core): import_lastpass — TOTP base32 → TotpConfig + +Successful base32 decode attaches a SHA1/6/30s Totp config to +LoginCore.totp. Bad base32 emits a warning and imports the login +without TOTP rather than skipping the row entirely. + +Refactors map_row to return (Option, Option) +so a single row can produce both an item and a warning." +``` + +--- + +## Task 5: SecureNote rows — `url == "http://sn"` marker + +**Files:** +- Modify: `crates/relicario-core/src/import_lastpass.rs` +- Modify: `crates/relicario-core/tests/import_lastpass.rs` + +Per spec D10: rows whose `url` column is exactly `"http://sn"` are LastPass secure notes. They map to `SecureNoteCore` with `extra` becoming the note body — verbatim, even when LastPass packed structured data (cards, addresses) into it. + +- [ ] **Step 1: Write the failing tests** + +Append to `crates/relicario-core/tests/import_lastpass.rs`: + +```rust +#[test] +fn url_http_sn_maps_to_secure_note() { + let csv = format!( + "{HEADER}\n\ + http://sn,,,,The body of the note,My Note,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(items.len(), 1); + assert_eq!(items[0].title, "My Note"); + match &items[0].core { + ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), "The body of the note"), + other => panic!("expected SecureNote, got {:?}", other), + } +} + +#[test] +fn secure_note_does_not_require_password() { + // SecureNote rows have empty password; that must not trigger the + // `missing password` skip path (which is Login-only). + let csv = format!("{HEADER}\nhttp://sn,,,,note text,Title,,"); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty(), "{:?}", warnings); + assert_eq!(items.len(), 1); +} + +#[test] +fn secure_note_passes_through_grouping_and_favorite() { + let csv = format!("{HEADER}\nhttp://sn,,,,body,Title,Personal,1"); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items[0].group.as_deref(), Some("Personal")); + assert!(items[0].favorite); +} + +#[test] +fn secure_note_preserves_structured_extra_verbatim() { + // LastPass packs structured note data (e.g. credit cards) into `extra` + // using their own key:value format. We do NOT auto-parse it — verbatim + // pass-through, per spec D10. + let csv_body = "NoteType:Credit Card\nNumber:4111111111111111\nCVV:123"; + let csv = format!( + "{HEADER}\n\ + http://sn,,,,\"{csv_body}\",Visa,,", + csv_body = csv_body, + ); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + match &items[0].core { + ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), csv_body), + _ => unreachable!(), + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: FAIL — `url_http_sn_maps_to_secure_note` skips the row (missing password warning). + +- [ ] **Step 3: Branch on the `http://sn` marker** + +Edit `crates/relicario-core/src/import_lastpass.rs`. Add the `SecureNoteCore` import at the top: + +```rust +use crate::item_types::{ItemCore, LoginCore, SecureNoteCore}; +``` + +Then, in `map_row`, after extracting the columns and the `name.is_empty()` check, branch on the URL marker BEFORE the password-required check. Replace the `if password.is_empty() { ... }` block and everything below it down to the trailing `(Some(item), warning)` return with: + +```rust + 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 parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; + + let mut warning: Option = 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, + }), + _ => { + 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) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: PASS — all 15 tests green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/src/import_lastpass.rs \ + crates/relicario-core/tests/import_lastpass.rs +git commit -m "feat(core): import_lastpass — SecureNote rows + +Rows with url == \"http://sn\" map to SecureNoteCore with extra +copied verbatim into the body. LastPass-packed structured data +(credit cards, addresses) flows through unparsed — users can +re-categorize manually post-import. + +SecureNote rows skip the password-required check that applies +to Logins." +``` + +--- + +## Task 6: Robustness — bad URL warnings, header validation, unicode + +**Files:** +- Modify: `crates/relicario-core/src/import_lastpass.rs` +- Modify: `crates/relicario-core/tests/import_lastpass.rs` + +Tightening the parser against real-world inputs: unparseable URLs in login rows emit a warning (and import without URL); header-row mismatches surface a clear `ImportCsvHeader` error; quoted commas, unicode, and non-`1` `fav` values are robust. + +- [ ] **Step 1: Write the failing tests** + +Append to `crates/relicario-core/tests/import_lastpass.rs`: + +```rust +#[test] +fn login_with_unparseable_url_imports_with_url_none_and_warns() { + let csv = format!( + "{HEADER}\n\ + not-a-real-url,alice,hunter2,,,Site,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items.len(), 1); + match &items[0].core { + ItemCore::Login(l) => assert!(l.url.is_none()), + _ => unreachable!(), + } + assert_eq!(warnings.len(), 1); + assert!(warnings[0].message.contains("URL"), "msg: {}", warnings[0].message); + assert_eq!(warnings[0].title.as_deref(), Some("Site")); +} + +#[test] +fn header_with_extra_column_is_rejected() { + let bad = "url,username,password,totp,extra,name,grouping,fav,EXTRA\nhttps://x,u,p,,,T,,"; + let err = parse_lastpass_csv(bad.as_bytes()).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("LastPass") || msg.contains("expected"), "msg: {msg}"); +} + +#[test] +fn header_with_wrong_column_order_is_rejected() { + let swapped = "name,url,username,password,totp,extra,grouping,fav\nT,https://x,u,p,,,,"; + let err = parse_lastpass_csv(swapped.as_bytes()).unwrap_err(); + assert!(format!("{err}").contains("expected")); +} + +#[test] +fn quoted_comma_in_extra_parses() { + let csv = format!( + "{HEADER}\n\ + https://x,u,p,,\"hint with, a comma\",Site,,", + ); + let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(items[0].notes.as_deref(), Some("hint with, a comma")); +} + +#[test] +fn unicode_title_round_trips() { + let csv = format!("{HEADER}\nhttps://x,u,p,,,Müllerstraße — café ☕,,"); + let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap(); + assert_eq!(items[0].title, "Müllerstraße — café ☕"); +} + +#[test] +fn empty_csv_after_header_returns_empty_vecs() { + let (items, warnings) = parse_lastpass_csv(HEADER.as_bytes()).unwrap(); + assert!(items.is_empty()); + assert!(warnings.is_empty()); +} + +#[test] +fn missing_header_is_rejected() { + // Empty input — csv reader treats first row as header (which doesn't exist). + let err = parse_lastpass_csv(b"").unwrap_err(); + let msg = format!("{err}"); + // Either ImportCsvHeader (header didn't match) or ImportCsvFormat (read + // failed). Both are acceptable; we just need a clear error. + assert!(msg.contains("LastPass") || msg.contains("CSV"), "msg: {msg}"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: FAIL on `login_with_unparseable_url_imports_with_url_none_and_warns` (no warning emitted) and possibly the empty-input test. + +- [ ] **Step 3: Wire up the URL warning + empty-input fix** + +Edit `crates/relicario-core/src/import_lastpass.rs`. In `map_row`, replace the `let parsed_url = ...` line with: + +```rust + 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 + } + } + }; +``` + +The current order of operations is `parsed_url` extracted before `warning` is declared. Reorder: declare `let mut warning: Option = None;` BEFORE `let parsed_url = ...`. Then move it down above the `let totp = ...` block (the existing TOTP path also uses `warning`, so they share). The warning slot can hold either a URL warning OR a TOTP warning — if both fire on one row, the URL warning wins (the `if warning.is_none()` guard above). This is acceptable for v1; users rarely have BOTH a bad URL AND bad TOTP base32 on the same row, and the warning text already says "row N (Title)" so it's traceable. + +The final ordering inside `map_row` (Login branch) is: +1. extract columns +2. check `name`, check `password` +3. `let mut warning: Option = None;` +4. `let parsed_url = ...` (may set warning) +5. `let totp = ...` (may set warning if URL didn't) +6. build `Item`, set group/favorite/notes +7. return `(Some(item), warning)` + +For empty input handling (the `missing_header_is_rejected` test): the `csv` crate's `headers()` call returns an `Err` for empty input, which already maps to `ImportCsvFormat` in our existing code path. Confirm with the test. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p relicario-core --test import_lastpass` +Expected: PASS — all 22 tests green. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/src/import_lastpass.rs \ + crates/relicario-core/tests/import_lastpass.rs +git commit -m "feat(core): import_lastpass — URL/header robustness + +Bad URLs in login rows downgrade to url: None with a warning +rather than skipping the row. Header mismatches (extra columns, +wrong order) surface ImportCsvHeader. Quoted commas, multi-line +extra, unicode all parse cleanly via the csv crate's defaults." +``` + +--- + +## Task 7: CLI — clap surface for `import lastpass` + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` + +Wire up the new top-level `Import` command with a `Lastpass` subcommand. Mirrors the structure of the existing `Backup { action: BackupAction }` group — clean, room to add `OnePassword` / `Bitwarden` later without churn. + +- [ ] **Step 1: Add the clap variants** + +Edit `crates/relicario-cli/src/main.rs`. After `Backup { action: BackupAction }` in the `Commands` enum (around line 101), insert: + +```rust + /// Import items from another password manager into the unlocked vault. + Import { + #[command(subcommand)] + action: ImportAction, + }, +``` + +After the `BackupAction` enum (around line 310), append: + +```rust +#[derive(Subcommand)] +enum ImportAction { + /// Import a LastPass CSV export into the unlocked vault. + /// Each row creates a new item with a freshly-minted ID; title + /// collisions are kept (no dedup). Failed rows are skipped and + /// reported on stderr. + Lastpass { + /// Path to the LastPass-format CSV export. + csv: PathBuf, + }, +} +``` + +In the `match cli.command` block in `main`, after `Commands::Backup { action } => cmd_backup(action),`, insert: + +```rust + Commands::Import { action } => cmd_import(action), +``` + +Add a stub `cmd_import` function above `cmd_init`: + +```rust +fn cmd_import(action: ImportAction) -> Result<()> { + match action { + ImportAction::Lastpass { csv } => cmd_import_lastpass(csv), + } +} + +fn cmd_import_lastpass(_csv: PathBuf) -> Result<()> { + bail!("not implemented yet — Task 8 lands the body") +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p relicario-cli` +Expected: PASS. + +- [ ] **Step 3: Verify the CLI exposes the command** + +Run: `cargo run -p relicario-cli -- import --help` +Expected: PASS, output mentions "lastpass" subcommand. + +Run: `cargo run -p relicario-cli -- import lastpass --help` +Expected: PASS, output describes the `csv` arg. + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): clap surface for \`import lastpass\` + +Adds the Import command group with a Lastpass subcommand. +Stub returns \`not implemented\` so the help text is reachable +ahead of the body landing in Task 8." +``` + +--- + +## Task 8: CLI — `cmd_import_lastpass` implementation + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` + +Implements the full data flow from spec §"Import LastPass": unlock → parse → encrypt each item → write `items/.enc` files → upsert manifest → encrypt + write `manifest.enc` → ONE `git add ... && git commit` covering all paths. Prints stderr progress every 50 items. + +- [ ] **Step 1: Replace the stub with the real implementation** + +Replace the body of `cmd_import_lastpass` in `crates/relicario-cli/src/main.rs` with: + +```rust +fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::import_lastpass::parse_lastpass_csv; + + let csv_bytes = fs::read(&csv_path) + .with_context(|| format!("failed to read CSV {}", csv_path.display()))?; + + let (items, warnings) = parse_lastpass_csv(&csv_bytes)?; + + if items.is_empty() { + // Print all warnings so the user sees why nothing imported. + for w in &warnings { + print_warning(w); + } + bail!( + "imported 0 items from {} — see warnings above", + csv_path.display() + ); + } + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + + let total = items.len(); + let mut written_paths: Vec = Vec::with_capacity(items.len() + 1); + + for (idx, item) in items.iter().enumerate() { + vault.save_item(item)?; + manifest.upsert(item); + written_paths.push(format!("items/{}.enc", item.id.as_str())); + + let n = idx + 1; + if n % 50 == 0 || n == total { + eprintln!("[{n}/{total}] importing..."); + } + } + + vault.save_manifest(&manifest)?; + written_paths.push("manifest.enc".into()); + + let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect(); + let csv_filename = csv_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("lastpass.csv"); + commit_paths( + &vault, + &format!("import: {} items from LastPass ({})", total, csv_filename), + &path_refs, + )?; + + for w in &warnings { + print_warning(w); + } + eprintln!( + "Imported {}, skipped {} (see warnings above)", + total, + warnings.iter().filter(|w| w.message.contains("skipped")).count() + ); + Ok(()) +} + +fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) { + let prefix = match &w.title { + Some(t) => format!("row {} ({}):", w.row, t), + None => format!("row {}:", w.row), + }; + eprintln!("warning: {prefix} {}", w.message); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p relicario-cli` +Expected: PASS. + +- [ ] **Step 3: Smoke test against an inline CSV** + +Run a minimal end-to-end smoke test by hand: + +```bash +TMPDIR=$(mktemp -d) +cd "$TMPDIR" +cargo run -p relicario-cli -- init --image <(printf '\xff\xd8\xff' > /dev/null; echo /dev/null) 2>&1 | head -5 || true +``` + +Skip if too fiddly — the integration tests in Task 9 exercise the full path. + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): cmd_import_lastpass — full data flow + +Unlocks the vault, parses the CSV, encrypts each item, writes +items/.enc and manifest.enc, then a single +\`git add … && git commit\` covers all of them. Stderr progress +every 50 items + final summary. Exit non-zero only when zero +items imported." +``` + +--- + +## Task 9: CLI — integration tests + LastPass fixture + +**Files:** +- Create: `crates/relicario-cli/tests/import_lastpass.rs` +- Create: `crates/relicario-cli/tests/fixtures/lastpass-sample.csv` + +End-to-end via `TestVault`. Covers: standard logins, TOTP, SecureNote rows, malformed rows produce warnings + non-zero status only when 0 items imported, single git commit covers all writes. + +- [ ] **Step 1: Create the fixture CSV** + +Create `crates/relicario-cli/tests/fixtures/lastpass-sample.csv`: + +```csv +url,username,password,totp,extra,name,grouping,fav +https://github.com/login,alice@example.com,hunter2-strong,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,One-time URL: https://github.com/recover,GitHub,Work,1 +https://gmail.com,bob@example.com,p@ssw0rd-2026,,,Gmail,Personal, +https://news.ycombinator.com,charlie,hn-secret,,,Hacker News,, +https://aws.console,d-user,aws-pass,!!!not-base32!!!,,AWS,Work, +http://sn,,,,Wifi password: hunter2hunter2,Home Wifi,Personal, +http://sn,,,,"NoteType:Credit Card +Number:4111111111111111 +Expiry:01/2030 +CVV:123",Visa Card,Personal, +https://日本語.example,user,pass,,,日本語サイト,, +not-a-real-url,user,pass,,,Bad URL,, +,,,,,,, +https://x,user,,,,No Password,, +https://example.com,user,p,,"multi +line +notes",Multiline,, +``` + +The fixture intentionally includes: +- A standard login with TOTP + grouping + favorite (row 1). +- A login with empty TOTP (row 2). +- A login with no grouping or favorite (row 3). +- A login with a bad TOTP base32 (row 4 — imports without TOTP, with a warning). +- A SecureNote (row 5). +- A SecureNote with packed structured data (row 6). +- A login with a unicode title (row 7). +- A login with a bad URL (row 8 — imports with `url=None`, warning). +- A row with an empty `name` (row 9 — skipped, warning). +- A row with empty `password` for a login (row 10 — skipped, warning). +- A login with multi-line `extra` (row 11 — preserved). + +Expected counts after import: 9 items (8 logins + 2 SecureNotes minus the 2 skipped rows, plus the multi-line login = 9), 4 warnings (bad TOTP, bad URL, missing name, missing password). + +Wait — recount: rows 1–11. Successful imports: rows 1, 2, 3, 4 (login, no TOTP, with warning), 5 (note), 6 (note), 7, 8 (login no URL, with warning), 11. That's 9 items. Skipped: rows 9 (no name), 10 (no password). Warnings: bad TOTP (row 4), missing name (row 9), missing password (row 10), bad URL (row 8). That's 4 warnings. + +- [ ] **Step 2: Write the integration test** + +Create `crates/relicario-cli/tests/import_lastpass.rs`: + +```rust +mod common; +use common::TestVault; + +const FIXTURE: &str = "tests/fixtures/lastpass-sample.csv"; + +fn fixture_path() -> std::path::PathBuf { + // Manifest dir = crates/relicario-cli; the fixture is relative to it. + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE) +} + +#[test] +fn imports_logins_secure_notes_and_warns_on_skipped() { + let v = TestVault::init(); + + let out = v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + assert!( + out.status.success(), + "import failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + let stderr = String::from_utf8(out.stderr).unwrap(); + // 9 items expected (see fixture comment). + assert!(stderr.contains("Imported 9"), "stderr: {stderr}"); + assert!(stderr.contains("skipped 2"), "stderr: {stderr}"); + + // Each warning surfaces. + assert!(stderr.contains("invalid base32 TOTP"), "TOTP warning missing"); + assert!(stderr.contains("invalid URL"), "URL warning missing"); + assert!(stderr.contains("missing `name`"), "name-missing warning missing"); + assert!(stderr.contains("missing `password`"), "password-missing warning missing"); +} + +#[test] +fn list_after_import_shows_imported_titles() { + let v = TestVault::init(); + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + + let out = v.run(&["list"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("GitHub")); + assert!(stdout.contains("Gmail")); + assert!(stdout.contains("Home Wifi")); + assert!(stdout.contains("Visa Card")); + assert!(stdout.contains("日本語サイト")); + // Skipped rows must NOT appear. + assert!(!stdout.contains("No Password"), + "row with no password should have been skipped"); +} + +#[test] +fn import_creates_a_single_git_commit() { + let v = TestVault::init(); + + // Count commits before. + let before = std::process::Command::new("git") + .arg("-C").arg(v.path()) + .args(["rev-list", "--count", "HEAD"]) + .output().unwrap(); + let before_n: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap(); + + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + + let after = std::process::Command::new("git") + .arg("-C").arg(v.path()) + .args(["rev-list", "--count", "HEAD"]) + .output().unwrap(); + let after_n: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap(); + + assert_eq!(after_n, before_n + 1, "expected exactly one new commit"); + + // Commit message includes the count + "LastPass". + let log = std::process::Command::new("git") + .arg("-C").arg(v.path()) + .args(["log", "-1", "--pretty=%s"]) + .output().unwrap(); + let subject = String::from_utf8(log.stdout).unwrap(); + assert!(subject.contains("9 items")); + assert!(subject.contains("LastPass")); +} + +#[test] +fn import_with_zero_items_exits_nonzero() { + let v = TestVault::init(); + + // Header-only CSV with one bad row → 0 items. + let bad_csv = v.path().join("empty.csv"); + std::fs::write( + &bad_csv, + "url,username,password,totp,extra,name,grouping,fav\n,,,,,,,\n", + ).unwrap(); + + let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]); + assert!(!out.status.success(), "expected non-zero exit on zero items"); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains("imported 0 items"), "stderr: {stderr}"); +} + +#[test] +fn import_rejects_unrecognized_header() { + let v = TestVault::init(); + let bad_csv = v.path().join("wrong.csv"); + std::fs::write(&bad_csv, "name,url,user,pass\nA,https://x,u,p\n").unwrap(); + + let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("LastPass") || stderr.contains("expected"), + "stderr: {stderr}", + ); +} + +#[test] +fn imported_items_keep_unique_ids_across_runs() { + // Decision D12: two imports of the same CSV must not collide. + let v = TestVault::init(); + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]); + + let out = v.run(&["list"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + // Each title imported twice — count occurrences of "GitHub" must be 2. + let github_count = stdout.matches("GitHub").count(); + assert_eq!(github_count, 2, "stdout: {stdout}"); +} +``` + +- [ ] **Step 3: Run the tests** + +Run: `cargo test -p relicario-cli --test import_lastpass` +Expected: PASS — all 6 tests green. + +- [ ] **Step 4: Run the full test suite to confirm no regressions** + +Run: `cargo test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/tests/import_lastpass.rs \ + crates/relicario-cli/tests/fixtures/lastpass-sample.csv +git commit -m "test(cli): integration coverage for \`import lastpass\` + +Fixture CSV exercises 11 rows: standard login, login + TOTP, +SecureNote (plain + structured), unicode title, bad URL, +malformed rows. Tests verify item count, single git commit, +warning surface area, exit code, and ID uniqueness across +back-to-back imports." +``` + +--- + +## Task 10: WASM — `parse_lastpass_csv_json` export + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` + +Thin JSON-encoding wrapper around `core::parse_lastpass_csv`. The SW receives the file bytes (as a `Uint8Array`), calls this, and gets back JSON for the preview UI. Encryption + commit is a separate path (still on the SW side, using the existing `item_encrypt` / `manifest_encrypt` bridges). + +- [ ] **Step 1: Write a native test for the bridge** + +Append to the existing `#[cfg(test)] mod session_tests` block at the bottom of `crates/relicario-wasm/src/lib.rs` (alongside `manifest_round_trip_via_handle`): + +```rust + #[test] + fn parse_lastpass_csv_json_returns_items_and_warnings() { + // Row 1 imports cleanly; row 2 has an empty `name` and is skipped + // with a warning. + let csv = "url,username,password,totp,extra,name,grouping,fav\n\ + https://x,alice,hunter2,,,GitHub,Work,1\n\ + https://y,bob,hunter2,,,,,"; + let json = super::parse_lastpass_csv_json(csv.as_bytes()).unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(v["items"].as_array().unwrap().len(), 1); + assert_eq!(v["warnings"].as_array().unwrap().len(), 1); + assert!(v["warnings"][0]["message"].as_str().unwrap().contains("name")); + // The item's title round-trips as a plain JSON string. + assert_eq!(v["items"][0]["title"].as_str().unwrap(), "GitHub"); + } + + #[test] + fn parse_lastpass_csv_json_propagates_header_errors() { + let bad = "name,user,pass\nA,u,p\n"; + let err = super::parse_lastpass_csv_json(bad.as_bytes()).unwrap_err(); + // JsError surfaces through Display via the inner error. + let _ = err; // smoke-only — JsError doesn't expose its message in a native test. + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p relicario-wasm session_tests` +Expected: FAIL on missing `parse_lastpass_csv_json` function. + +- [ ] **Step 3: Implement the bridge** + +Append at the end of `crates/relicario-wasm/src/lib.rs` (after the `// ── Backup container bridge ──...` block, before the `#[cfg(test)] mod session_tests`): + +```rust +// ── LastPass CSV importer bridge ──────────────────────────────────────────── + +use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv; + +/// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`. +/// +/// Items are returned as full `Item` JSON objects with freshly-minted IDs +/// and timestamps already populated. The SW caller is responsible for +/// encrypting + writing them; this bridge stays pure so the preview UI +/// can render counts without committing anything. +#[wasm_bindgen] +pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result { + let (items, warnings) = core_parse_lastpass_csv(csv_bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + + let json = serde_json::json!({ + "items": items, + "warnings": warnings, + }); + Ok(json.to_string()) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p relicario-wasm` +Expected: PASS. + +- [ ] **Step 5: Verify the WASM target still builds** + +Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs +git commit -m "feat(wasm): parse_lastpass_csv_json bridge + +Returns { items: [Item], warnings: [ImportWarning] } as a JSON +string. The items already have fresh IDs + timestamps; the SW +caller encrypts and writes them through the existing +item_encrypt + manifest_encrypt bridges." +``` + +--- + +## Task 11: SW — message types for parse + commit + +**Files:** +- Modify: `extension/src/shared/messages.ts` + +Two new popup-message types: `parse_lastpass_csv` (parse only, no vault state needed — but kept popup-only because the WASM lives in the SW) and `import_lastpass_commit` (requires unlock + git host). + +- [ ] **Step 1: Add the request types** + +Edit `extension/src/shared/messages.ts`. In the `PopupMessage` union (around line 52-58, alongside `export_backup` / `restore_backup`), append: + +```ts + | { type: 'parse_lastpass_csv'; bytes: ArrayBuffer } + | { type: 'import_lastpass_commit'; items: Item[] }; +``` + +In the `POPUP_ONLY_TYPES` set (around line 151-164), append two entries to the `Set<...>` arg array (just before the trailing `'export_backup', 'restore_backup'`): + +```ts + 'parse_lastpass_csv', 'import_lastpass_commit', +``` + +After `RestoreBackupResponse` (around line 170-174), append: + +```ts +export interface ParseLastPassCsvResponse extends Extract { + data: { + items: Item[]; + warnings: Array<{ row: number; title?: string; message: string }>; + }; +} + +export interface ImportLastPassCommitResponse extends Extract { + data: { + summary: { itemCount: number }; + }; +} +``` + +- [ ] **Step 2: Verify type-check** + +Run: `cd extension && pnpm tsc --noEmit` +Expected: PASS (the SW handler doesn't exist yet, but type-only changes don't trigger handler errors). + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/shared/messages.ts +git commit -m "feat(ext/shared): message types for LastPass import + +Adds parse_lastpass_csv (preview) and import_lastpass_commit +(write) to the popup-only message set, plus typed response +helpers. SW handlers + UI follow in Tasks 12-14." +``` + +--- + +## Task 12: SW — `parse_lastpass_csv` and `import_lastpass_commit` handlers + +**Files:** +- Modify: `extension/src/service-worker/router/popup-only.ts` + +Two handler arms. Parse is a pure pass-through to WASM. Commit takes the (already-parsed-and-edited) items list, encrypts each via `item_encrypt`, writes it via `git.writeFile`, then upserts the manifest entry, and finally writes `manifest.enc` last. + +- [ ] **Step 1: Add the handler arms** + +Edit `extension/src/service-worker/router/popup-only.ts`. After the closing brace of `case 'restore_backup':` (just before the closing `}` of the outer `switch`, around line 510), insert: + +```ts + case 'parse_lastpass_csv': { + try { + const json: string = state.wasm.parse_lastpass_csv_json(new Uint8Array(msg.bytes)); + const parsed = JSON.parse(json) as { + items: Item[]; + warnings: Array<{ row: number; title?: string; message: string }>; + }; + return { ok: true, data: parsed }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } + + case 'import_lastpass_commit': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + if (msg.items.length === 0) return { ok: false, error: 'no items to import' }; + + try { + const total = msg.items.length; + for (let i = 0; i < msg.items.length; i++) { + const item = msg.items[i]; + // Items arrive with IDs already minted by the WASM bridge. We + // overwrite that with a fresh extension-generated ID so the SW + // remains the single ID-issuance authority for new items in the + // remote — same pattern as `add_item`. + const id = state.wasm.new_item_id(); + const reIdItem: Item = { ...item, id }; + + await vault.encryptAndWriteItem( + state.gitHost, handle, id, reIdItem, + `import: ${reIdItem.title} (${i + 1}/${total})`, + ); + state.manifest.items[id] = itemToManifestEntry(reIdItem); + } + + await vault.encryptAndWriteManifest( + state.gitHost, handle, state.manifest, + `manifest: import ${total} items from LastPass`, + ); + return { ok: true, data: { summary: { itemCount: total } } }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } +``` + +- [ ] **Step 2: Verify type-check** + +Run: `cd extension && pnpm tsc --noEmit` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/service-worker/router/popup-only.ts +git commit -m "feat(ext/sw): parse + commit handlers for LastPass import + +parse_lastpass_csv is a pure pass-through to the WASM bridge. +import_lastpass_commit re-mints each item's ID via +state.wasm.new_item_id() (same pattern as add_item), encrypts +and writes per-item via git.writeFile, then writes the manifest +last. Per-item commits + a final manifest commit — extension +GitHost has no atomic-batch API, so the single-commit semantics +the CLI provides aren't replicable here." +``` + +--- + +## Task 13: SW — handler unit tests + +**Files:** +- Create: `extension/src/service-worker/__tests__/import.test.ts` + +Mocked WASM + git host; covers the parse pass-through, the commit happy path, the vault-locked rejection, and the per-item write count. + +- [ ] **Step 1: Write the test** + +Create `extension/src/service-worker/__tests__/import.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fakeHost = { + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileCreateOnly: vi.fn(), + deleteFile: vi.fn(), + listDir: vi.fn(), + lastCommit: vi.fn(), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), +}; + +vi.mock('../session', () => ({ + setCurrent: vi.fn(), + getCurrent: vi.fn(() => ({ value: 1 })), + clearCurrent: vi.fn(), + requireCurrent: vi.fn(), +})); + +vi.mock('../vault', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + encryptAndWriteItem: vi.fn().mockResolvedValue(undefined), + encryptAndWriteManifest: vi.fn().mockResolvedValue(undefined), + }; +}); + +import { handle, type PopupState } from '../router/popup-only'; +import * as vault from '../vault'; +import type { Manifest, Item } from '../../shared/types'; + +const FAKE_SENDER = { + url: 'chrome-extension://x/vault.html', + id: 'x', + frameId: 0, +} as unknown as chrome.runtime.MessageSender; + +const EMPTY_MANIFEST: Manifest = { schema_version: 2, items: {} } as Manifest; + +function fakeWasm() { + let counter = 0; + return { + parse_lastpass_csv_json: vi.fn().mockReturnValue(JSON.stringify({ + items: [ + sampleItem('GitHub'), + sampleItem('Gmail'), + ], + warnings: [{ row: 3, title: 'No Pass', message: 'missing `password` — skipped' }], + })), + new_item_id: vi.fn(() => `newid${++counter}`.padEnd(16, '0')), + }; +} + +function sampleItem(title: string): Item { + return { + id: 'placeholder-id00', + title, + type: 'login', + tags: [], + favorite: false, + created: 1000, + modified: 1000, + core: { type: 'login', username: 'u', password: 'p' }, + sections: [], + attachments: [], + field_history: {}, + } as unknown as Item; +} + +describe('parse_lastpass_csv handler', () => { + beforeEach(() => { + (globalThis as { chrome?: unknown }).chrome = { + storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() } }, + }; + }); + + it('returns items + warnings from the WASM bridge', async () => { + const state: PopupState = { + manifest: EMPTY_MANIFEST, + gitHost: fakeHost as never, + wasm: fakeWasm(), + }; + const result = await handle( + { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, + state, + FAKE_SENDER, + ); + expect(result.ok).toBe(true); + if (result.ok) { + const data = result.data as { items: Item[]; warnings: unknown[] }; + expect(data.items).toHaveLength(2); + expect(data.warnings).toHaveLength(1); + } + }); + + it('surfaces WASM errors as ok:false', async () => { + const state: PopupState = { + manifest: EMPTY_MANIFEST, + gitHost: fakeHost as never, + wasm: { + parse_lastpass_csv_json: vi.fn(() => { throw new Error('bad header'); }), + }, + }; + const result = await handle( + { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(0) }, + state, + FAKE_SENDER, + ); + expect(result).toEqual({ ok: false, error: 'bad header' }); + }); +}); + +describe('import_lastpass_commit handler', () => { + beforeEach(() => { + (vault.encryptAndWriteItem as ReturnType).mockClear(); + (vault.encryptAndWriteManifest as ReturnType).mockClear(); + (globalThis as { chrome?: unknown }).chrome = { + storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() } }, + }; + }); + + it('encrypts + writes each item, manifest last', async () => { + const state: PopupState = { + manifest: { ...EMPTY_MANIFEST, items: {} }, + gitHost: fakeHost as never, + wasm: fakeWasm(), + }; + + const result = await handle( + { + type: 'import_lastpass_commit', + items: [sampleItem('A'), sampleItem('B'), sampleItem('C')], + }, + state, + FAKE_SENDER, + ); + expect(result.ok).toBe(true); + if (result.ok) { + const data = result.data as { summary: { itemCount: number } }; + expect(data.summary.itemCount).toBe(3); + } + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(3); + expect(vault.encryptAndWriteManifest).toHaveBeenCalledTimes(1); + + // Manifest must have been re-keyed with the WASM-minted IDs (newid1, newid2, newid3). + expect(Object.keys(state.manifest!.items)).toHaveLength(3); + for (const key of Object.keys(state.manifest!.items)) { + expect(key).toMatch(/^newid\d/); + } + }); + + it('rejects when vault is locked', async () => { + const state: PopupState = { manifest: null, gitHost: null, wasm: fakeWasm() }; + const result = await handle( + { type: 'import_lastpass_commit', items: [sampleItem('X')] }, + state, + FAKE_SENDER, + ); + expect(result).toEqual({ ok: false, error: 'vault_locked' }); + }); + + it('rejects an empty items list', async () => { + const state: PopupState = { + manifest: EMPTY_MANIFEST, + gitHost: fakeHost as never, + wasm: fakeWasm(), + }; + const result = await handle( + { type: 'import_lastpass_commit', items: [] }, + state, + FAKE_SENDER, + ); + expect(result).toEqual({ ok: false, error: 'no items to import' }); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd extension && pnpm vitest run src/service-worker/__tests__/import.test.ts` +Expected: PASS — 5 tests green. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/service-worker/__tests__/import.test.ts +git commit -m "test(ext/sw): unit tests for parse + commit handlers + +Mocks the WASM bridge and vault helpers. Covers: +- parse_lastpass_csv pass-through + error surface +- commit happy path: 3 items → 3 encryptAndWriteItem + + 1 encryptAndWriteManifest call +- vault_locked + empty-items rejections +- IDs re-minted by SW so manifest keys match the new IDs" +``` + +--- + +## Task 14: SW — router test for new message types + +**Files:** +- Modify: `extension/src/service-worker/router/__tests__/router.test.ts` + +Confirms the router accepts `parse_lastpass_csv` / `import_lastpass_commit` from the popup OR vault tab, and rejects them from setup tabs and content scripts. Mirrors the existing `export_backup / restore_backup sender check` block. + +- [ ] **Step 1: Add the test block** + +Append to `extension/src/service-worker/router/__tests__/router.test.ts` (after the `export_backup / restore_backup sender check` describe block, around line 838): + +```ts +// --- parse_lastpass_csv / import_lastpass_commit sender check --- + +describe('parse_lastpass_csv / import_lastpass_commit sender check', () => { + it('accepts vault tab for parse_lastpass_csv', async () => { + const state = makeState(); + const result = await route( + { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, + state, + makeVaultSender(), + ); + expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('accepts popup for parse_lastpass_csv', async () => { + const state = makeState(); + const result = await route( + { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, + state, + makePopupSender(), + ); + expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('rejects setup tab for parse_lastpass_csv', async () => { + const state = makeState(); + const result = await route( + { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, + state, + makeSetupSender(), + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('rejects content top frame for import_lastpass_commit', async () => { + const state = makeState(); + const result = await route( + { type: 'import_lastpass_commit', items: [] }, + state, + makeContentSender('https://example.com'), + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd extension && pnpm vitest run src/service-worker/router/__tests__/router.test.ts` +Expected: PASS — all router tests green (existing tests + 4 new). + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/service-worker/router/__tests__/router.test.ts +git commit -m "test(ext/router): sender matrix for LastPass import messages" +``` + +--- + +## Task 15: Vault tab — Import panel UI + +**Files:** +- Create: `extension/src/vault/components/import-panel.ts` +- Modify: `extension/src/vault/vault.ts` +- Modify: `extension/src/popup/components/settings-vault.ts` + +A panel mounted at `vault.html#import` with a file picker, a preview block ("142 logins, 17 notes, 3 skipped — proceed?"), a confirm button, an inline progress indicator, and a warnings list rendered after the import finishes. The popup's settings-vault gets an "Import →" button next to the existing "Backup & restore →". + +- [ ] **Step 1: Create the panel component** + +Create `extension/src/vault/components/import-panel.ts`: + +```ts +import { sendMessage } from '../../shared/state'; +import type { Item } from '../../shared/types'; + +type ViewMode = 'idle' | 'parsing' | 'preview' | 'committing' | 'done'; + +interface PreviewState { + items: Item[]; + warnings: Array<{ row: number; title?: string; message: string }>; + loginCount: number; + noteCount: number; +} + +let mode: ViewMode = 'idle'; +let preview: PreviewState | null = null; + +export function renderImportPanel(app: HTMLElement): void { + app.innerHTML = ` +
+

Import

+ +
+

LastPass CSV

+

Pick the CSV exported from LastPass + (More options → Advanced → Export). + Items are added to the unlocked vault with fresh IDs. + Title collisions are kept — no dedup. Failed rows are + skipped and reported below.

+ + + + + + + + +
+
+ `; + + wireImport(app); +} + +function wireImport(scope: HTMLElement): void { + const fileEl = scope.querySelector('#lp-file') as HTMLInputElement; + const previewEl = scope.querySelector('#lp-preview') as HTMLElement; + const previewTx = scope.querySelector('#lp-preview-text') as HTMLElement; + const confirmBt = scope.querySelector('#lp-confirm-btn') as HTMLButtonElement; + const cancelBt = scope.querySelector('#lp-cancel-btn') as HTMLButtonElement; + const progressE = scope.querySelector('#lp-progress') as HTMLElement; + const progressT = scope.querySelector('#lp-progress-text') as HTMLElement; + const warningsE = scope.querySelector('#lp-warnings') as HTMLElement; + const warningsL = scope.querySelector('#lp-warning-list') as HTMLElement; + + fileEl.addEventListener('change', async () => { + const file = fileEl.files?.[0]; + if (!file) return; + if (mode !== 'idle' && mode !== 'done') return; + resetPanels(previewEl, progressE, warningsE); + + mode = 'parsing'; + progressE.classList.remove('hidden'); + progressT.textContent = 'Parsing…'; + + try { + const bytes = await file.arrayBuffer(); + const resp = await sendMessage({ type: 'parse_lastpass_csv', bytes }); + progressE.classList.add('hidden'); + if (!resp.ok) { + previewTx.textContent = `Failed to parse: ${resp.error}`; + previewEl.classList.remove('hidden'); + confirmBt.classList.add('hidden'); + cancelBt.classList.remove('hidden'); + mode = 'idle'; + return; + } + + const data = (resp as { ok: true; data: PreviewState }).data; + preview = aggregate(data); + previewTx.textContent = + `${preview.loginCount} logins, ${preview.noteCount} notes, ` + + `${preview.warnings.length} skipped/warn — proceed?`; + previewEl.classList.remove('hidden'); + confirmBt.classList.remove('hidden'); + cancelBt.classList.remove('hidden'); + confirmBt.disabled = preview.items.length === 0; + mode = 'preview'; + } catch (e) { + progressE.classList.add('hidden'); + previewTx.textContent = `Failed: ${(e as Error).message}`; + previewEl.classList.remove('hidden'); + mode = 'idle'; + } + }); + + cancelBt.addEventListener('click', () => { + preview = null; + fileEl.value = ''; + resetPanels(previewEl, progressE, warningsE); + mode = 'idle'; + }); + + confirmBt.addEventListener('click', async () => { + if (mode !== 'preview' || !preview) return; + mode = 'committing'; + confirmBt.disabled = true; + cancelBt.disabled = true; + progressE.classList.remove('hidden'); + progressT.textContent = `Importing ${preview.items.length} items…`; + + try { + const resp = await sendMessage({ + type: 'import_lastpass_commit', + items: preview.items, + }); + progressE.classList.add('hidden'); + if (!resp.ok) { + previewTx.textContent = `Import failed: ${resp.error}`; + mode = 'idle'; + confirmBt.disabled = false; + cancelBt.disabled = false; + return; + } + + const data = (resp as { ok: true; data: { summary: { itemCount: number } } }).data; + previewTx.textContent = + `Imported ${data.summary.itemCount} items. Reload the sidebar to see them.`; + confirmBt.classList.add('hidden'); + cancelBt.textContent = 'Done'; + + if (preview.warnings.length > 0) { + warningsL.innerHTML = preview.warnings + .map((w) => { + const head = w.title ? `row ${w.row} (${escapeText(w.title)})` : `row ${w.row}`; + return `
  • ${head}: ${escapeText(w.message)}
  • `; + }) + .join(''); + warningsE.classList.remove('hidden'); + } + mode = 'done'; + } catch (e) { + progressE.classList.add('hidden'); + previewTx.textContent = `Failed: ${(e as Error).message}`; + confirmBt.disabled = false; + cancelBt.disabled = false; + mode = 'idle'; + } + }); +} + +function aggregate(data: PreviewState): PreviewState { + let loginCount = 0; + let noteCount = 0; + for (const item of data.items) { + if (item.type === 'login') loginCount += 1; + else if (item.type === 'secure_note') noteCount += 1; + } + return { ...data, loginCount, noteCount }; +} + +function resetPanels(preview: HTMLElement, progress: HTMLElement, warnings: HTMLElement): void { + preview.classList.add('hidden'); + progress.classList.add('hidden'); + warnings.classList.add('hidden'); +} + +function escapeText(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +export function teardown(): void { + preview = null; + mode = 'idle'; +} +``` + +- [ ] **Step 2: Wire the panel into the vault tab** + +Edit `extension/src/vault/vault.ts`: + +In the `VaultView` union (around line 70), add `'import'`: + +```ts +type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup' | 'import'; +``` + +In the `parseHash` switch (around line 85-100), add `'import'` to the no-arg cases: + +```ts + case 'trash': + case 'devices': + case 'settings': + case 'settings-vault': + case 'field-history': + case 'backup': + case 'import': + return { view }; +``` + +At the top of the file (around line 19), add the import: + +```ts +import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; +``` + +In `teardownPaneComponents` (around line 419-424), call the new teardown: + +```ts +function teardownPaneComponents(): void { + teardownTrash(); + teardownDevices(); + teardownFieldHistory(); + teardownBackup(); + teardownImport(); +} +``` + +In the `renderPane` switch (around line 438-477), after `case 'backup':` add: + +```ts + case 'import': + renderImportPanel(pane); + break; +``` + +- [ ] **Step 3: Add the popup deep-link button** + +Edit `extension/src/popup/components/settings-vault.ts`. Around line 161-166 (the existing "backup & restore" section), add a sibling section right after it. Replace this block: + +```html +
    +
    backup & restore
    +
    + +
    +
    +``` + +with: + +```html +
    +
    backup & restore
    +
    + +
    +
    + +
    +
    import
    +
    + +
    +
    +``` + +Then in `wireHandlers()` around line 197 (next to `open-backup`), add: + +```ts + document.getElementById('open-import')?.addEventListener('click', () => openVaultTab('import')); +``` + +- [ ] **Step 4: Build and type-check** + +Run: `cd extension && pnpm tsc --noEmit && pnpm build` +Expected: PASS. + +- [ ] **Step 5: Manual smoke test** + +Out of scope to fully exercise here — the end-to-end UI path is covered by the vitest in Task 16 and by the integration tests in Task 9. If you have the extension loaded into a Chrome dev profile, load the rebuilt artifact, open the vault tab, click the popup's "LastPass CSV →" deep link, and confirm the panel renders. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/vault/components/import-panel.ts \ + extension/src/vault/vault.ts \ + extension/src/popup/components/settings-vault.ts +git commit -m "feat(ext/vault): Import panel — LastPass CSV + +New vault.html#import panel with a file picker, parse-preview +(\"N logins, M notes, K skipped — proceed?\"), confirm/cancel +buttons, inline progress, and a post-import warnings list. The +popup's settings-vault view links to it via a new +\"LastPass CSV →\" button next to \"Backup & restore →\"." +``` + +--- + +## Task 16: Vault tab — vitest for the Import panel + +**Files:** +- Create: `extension/src/vault/components/__tests__/import-panel.test.ts` + +Mocks `sendMessage`. Verifies: choosing a file fires `parse_lastpass_csv`; preview text reflects the parsed counts; clicking confirm fires `import_lastpass_commit` with the parsed items; warnings list renders after import. + +- [ ] **Step 1: Write the test** + +Create `extension/src/vault/components/__tests__/import-panel.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../shared/state', () => ({ + sendMessage: vi.fn(), + openVaultTab: vi.fn(), + registerHost: vi.fn(), + getState: vi.fn(), + setState: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: vi.fn(() => false), +})); + +import { sendMessage } from '../../../shared/state'; +import { renderImportPanel, teardown } from '../import-panel'; + +const mockSendMessage = sendMessage as ReturnType; + +function fakeItem(type: 'login' | 'secure_note', title: string) { + return { + id: 'fake0000', + title, + type, + tags: [], + favorite: false, + created: 0, + modified: 0, + core: type === 'login' + ? { type: 'login', username: 'u', password: 'p' } + : { type: 'secure_note', body: 'note' }, + sections: [], + attachments: [], + field_history: {}, + }; +} + +async function pickFile(app: HTMLElement, body: string): Promise { + const file = new File([body], 'export.csv', { type: 'text/csv' }); + const input = app.querySelector('#lp-file') as HTMLInputElement; + Object.defineProperty(input, 'files', { value: [file] }); + input.dispatchEvent(new Event('change')); + // Allow microtasks to drain. + await new Promise((r) => setTimeout(r, 10)); +} + +describe('Import panel', () => { + let app: HTMLElement; + + beforeEach(() => { + mockSendMessage.mockReset(); + teardown(); + document.body.innerHTML = '
    '; + app = document.getElementById('app')!; + }); + + it('parsing fires parse_lastpass_csv with the file bytes', async () => { + renderImportPanel(app); + mockSendMessage.mockResolvedValueOnce({ + ok: true, + data: { items: [fakeItem('login', 'A')], warnings: [] }, + }); + + await pickFile(app, 'url,username,password,totp,extra,name,grouping,fav\nhttps://x,u,p,,,A,,'); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect((mockSendMessage.mock.calls[0][0] as { type: string }).type).toBe('parse_lastpass_csv'); + }); + + it('preview text shows parsed counts', async () => { + renderImportPanel(app); + mockSendMessage.mockResolvedValueOnce({ + ok: true, + data: { + items: [fakeItem('login', 'A'), fakeItem('login', 'B'), fakeItem('secure_note', 'N')], + warnings: [{ row: 5, title: 'X', message: 'missing `password` — skipped' }], + }, + }); + + await pickFile(app, 'header,row,doesnt,matter,for,this,test,case'); + + const txt = (app.querySelector('#lp-preview-text') as HTMLElement).textContent ?? ''; + expect(txt).toContain('2 logins'); + expect(txt).toContain('1 notes'); + expect(txt).toContain('1 skipped'); + expect(txt).toContain('proceed?'); + }); + + it('confirm fires import_lastpass_commit with the parsed items', async () => { + renderImportPanel(app); + mockSendMessage.mockResolvedValueOnce({ + ok: true, + data: { items: [fakeItem('login', 'A'), fakeItem('login', 'B')], warnings: [] }, + }); + mockSendMessage.mockResolvedValueOnce({ + ok: true, + data: { summary: { itemCount: 2 } }, + }); + + await pickFile(app, 'header'); + (app.querySelector('#lp-confirm-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockSendMessage).toHaveBeenCalledTimes(2); + const second = mockSendMessage.mock.calls[1][0] as { + type: string; + items: unknown[]; + }; + expect(second.type).toBe('import_lastpass_commit'); + expect(second.items).toHaveLength(2); + }); + + it('renders warning list after a successful import', async () => { + renderImportPanel(app); + mockSendMessage.mockResolvedValueOnce({ + ok: true, + data: { + items: [fakeItem('login', 'A')], + warnings: [ + { row: 4, title: 'AWS', message: 'invalid base32 TOTP secret — login imported without TOTP' }, + ], + }, + }); + mockSendMessage.mockResolvedValueOnce({ + ok: true, + data: { summary: { itemCount: 1 } }, + }); + + await pickFile(app, 'header'); + (app.querySelector('#lp-confirm-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 10)); + + const list = app.querySelector('#lp-warning-list')?.textContent ?? ''; + expect(list).toContain('row 4'); + expect(list).toContain('AWS'); + expect(list).toContain('TOTP'); + }); + + it('cancel clears the preview', async () => { + renderImportPanel(app); + mockSendMessage.mockResolvedValueOnce({ + ok: true, + data: { items: [fakeItem('login', 'A')], warnings: [] }, + }); + + await pickFile(app, 'header'); + expect(app.querySelector('#lp-preview')!.classList.contains('hidden')).toBe(false); + + (app.querySelector('#lp-cancel-btn') as HTMLButtonElement).click(); + expect(app.querySelector('#lp-preview')!.classList.contains('hidden')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the tests** + +Run: `cd extension && pnpm vitest run src/vault/components/__tests__/import-panel.test.ts` +Expected: PASS — 5 tests green. + +- [ ] **Step 3: Run the full extension test suite to confirm no regressions** + +Run: `cd extension && pnpm test` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/vault/components/__tests__/import-panel.test.ts +git commit -m "test(ext/vault): vitest for the Import panel + +Mocks sendMessage. Covers: file-picker fires +parse_lastpass_csv, preview text matches the parsed counts, +confirm fires import_lastpass_commit with the parsed items, +warnings render after import, cancel clears the preview." +``` + +--- + +## Task 17: Update CHANGELOG + sanity-check the full suite + +**Files:** +- Modify: `CHANGELOG.md` + +Add an `Unreleased` entry under `### Added` documenting the new CLI command + extension panel. Bumps to `extension/package.json` and `extension/manifest.json` happen at v0.3.0 tagging time alongside the audit walk — this plan doesn't touch them. + +- [ ] **Step 1: Edit CHANGELOG** + +Edit `CHANGELOG.md`. Insert under the existing `## Unreleased` → `### Added` section (after the "Vault-tab Backup & Restore panel" entry, around line 47): + +```md +- **LastPass CSV import.** New `relicario import lastpass ` + command + vault-tab Import panel (`vault.html#import`). + Logins map to `Login` items; rows with `url == "http://sn"` + map to `SecureNote` (extra column → body verbatim, structured + data preserved as-is for manual re-categorization). TOTP + secrets in the `totp` column are base32-decoded into + `LoginCore.totp`; bad base32 surfaces a warning and the login + is imported without TOTP. Failed rows (missing `name`, missing + password on a login) are skipped with a per-row warning. + Each row gets a freshly-minted ID — re-running the import + creates duplicates rather than corrupting state. +- **Popup deep link to the Import panel.** `settings-vault` + gains an "import" section with a `LastPass CSV →` button + next to the existing `Backup & restore →` button. +``` + +- [ ] **Step 2: Run the full test suite — Rust** + +Run: `cargo test` +Expected: PASS. + +- [ ] **Step 3: Run the full test suite — extension** + +Run: `cd extension && pnpm test` +Expected: PASS. + +- [ ] **Step 4: Build the WASM target** + +Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` +Expected: PASS. + +- [ ] **Step 5: Build the extension** + +Run: `cd extension && pnpm build` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): LastPass CSV importer (Plan 3B) + +Documents \`relicario import lastpass \` and the vault-tab +Import panel under Unreleased / Added." +``` + +--- + +## Self-review — do this before merging + +After all tasks pass, walk this checklist with fresh eyes: + +1. **Spec coverage.** Confirm every D10–D13 decision and every row of the LastPass field-mapping table is exercised by a test: + - D10 ("LastPass CSV → Login + SecureNote") — Tasks 2, 5 + - D11 ("Failed rows skipped, exit 0 if any imported") — Tasks 6, 9 + - D12 ("Title collisions kept, fresh IDs") — Tasks 2, 9 + - D13 ("Single git commit") — Task 9 (CLI), Task 12 scope-decision note (extension) + - Field mapping: name/grouping/fav (Task 3), url/username/password (Task 2), totp (Task 4), extra (Tasks 3, 5) +2. **Atomicity claims.** Manifest is written last in both surfaces; orphan item files in the SW path between per-item commits don't pollute the manifest because the SW updates `state.manifest.items` in memory and only flushes once. +3. **No placeholders.** Search the diff for `TODO`, `unimplemented!`, `bail!("not implemented")`, `// fill in`. The Task 7 stub bail is replaced in Task 8; no other placeholders should remain. +4. **API consistency.** The WASM `parse_lastpass_csv_json` returns `{items, warnings}`. The SW `parse_lastpass_csv` response wraps it in `{ ok: true, data: { items, warnings } }`. The vault tab destructures `data.items` and `data.warnings`. Verify the field names match end-to-end. +5. **Memory hygiene.** `LoginCore.password` is `Zeroizing`; the parser already wraps the password column with `Zeroizing::new`. SecureNote bodies use `Zeroizing` per `SecureNoteCore`. CSV reader holds the password as a regular `String` mid-parse — acceptable for a one-shot import process; the CLI exits after the commit and the SW handler returns its scope. +6. **Error message clarity.** Run `cargo run -p relicario-cli -- import lastpass /tmp/no-such-file.csv` by hand — the error should be readable, not a backtrace. + +If anything is missing, fix it inline before the final task. Don't bolt on a "Task 18: cleanup" — fix the breaking task. + +--- + +## Execution handoff + +After saving the plan, offer the executor: + +**Plan complete and saved to `docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review between tasks, fast iteration. Best fit since the tasks are mostly independent (parser → CLI → WASM → SW → UI). + +**2. Inline Execution** — run tasks in the current session, batched with checkpoints. Better if you want to keep eyes on every step. + +Recommend Subagent-Driven and a fresh worktree at `.worktrees/lastpass-importer`.