Files
relicario/docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md
adlee-was-taken 39ae2ecbf3 style: capitalize "Relicario" in prose / UI / CLI help
Brand name uses capital R in user-facing text — extension UI strings,
CLI clap help / descriptions / error prose, markdown docs. Lowercase
preserved for the binary command, crate names, npm package, file
paths, env vars, and code identifiers.

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

2560 lines
90 KiB
Markdown
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.
# 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 <csv>` 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<Item>` + `Vec<ImportWarning>` 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` — D10D13, 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 && bun install && bun run 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
//! (D10D13 + the LastPass field-mapping table).
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::item::Item;
use crate::item_types::{ItemCore, LoginCore};
/// LastPass column order. The header row must contain these exact column
/// names in this exact order.
pub const EXPECTED_HEADER: &[&str] =
&["url", "username", "password", "totp", "extra", "name", "grouping", "fav"];
/// A row that was skipped, or partially imported with a downgrade
/// (e.g., login imported without TOTP).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportWarning {
/// 1-indexed row number in the CSV body (the header is row 0).
pub row: usize,
/// Title from the row's `name` column, if present and non-empty.
pub title: Option<String>,
/// Human-readable explanation, suitable for stderr / inline UI.
pub message: String,
}
/// Parse a LastPass CSV export.
///
/// Returns the parsed items (with fresh IDs and timestamps) and any
/// per-row warnings. The function only fails if the header is missing
/// or doesn't match `EXPECTED_HEADER`.
pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec<Item>, Vec<ImportWarning>)> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.flexible(false)
.from_reader(csv_bytes);
// Validate header.
let headers = reader
.headers()
.map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))?
.clone();
if headers.len() != EXPECTED_HEADER.len()
|| headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want)
{
return Err(RelicarioError::ImportCsvHeader(format!(
"expected `{}`, got `{}`",
EXPECTED_HEADER.join(","),
headers.iter().collect::<Vec<_>>().join(",")
)));
}
let mut items = Vec::new();
let mut warnings = Vec::new();
for (idx, record) in reader.records().enumerate() {
let row_num = idx + 1;
let record = match record {
Ok(r) => r,
Err(e) => {
warnings.push(ImportWarning {
row: row_num,
title: None,
message: format!("CSV parse error — skipped: {e}"),
});
continue;
}
};
match map_row(&record, row_num) {
Ok(item) => items.push(item),
Err(w) => warnings.push(w),
}
}
Ok((items, warnings))
}
/// Map a single CSV record to either an `Item` or an `ImportWarning`.
/// Column order is fixed by `EXPECTED_HEADER`.
fn map_row(record: &csv::StringRecord, row: usize) -> std::result::Result<Item, ImportWarning> {
let url = record.get(0).unwrap_or("").trim();
let username = record.get(1).unwrap_or("").trim();
let password = record.get(2).unwrap_or("");
let _totp = record.get(3).unwrap_or(""); // populated in Task 4
let extra = record.get(4).unwrap_or("");
let name = record.get(5).unwrap_or("").trim();
let _group = record.get(6).unwrap_or("").trim(); // populated in Task 3
let _fav = record.get(7).unwrap_or("").trim(); // populated in Task 3
if name.is_empty() {
return Err(ImportWarning {
row,
title: None,
message: "missing `name` — skipped".into(),
});
}
if password.is_empty() {
return Err(ImportWarning {
row,
title: Some(name.to_string()),
message: "missing `password` — skipped".into(),
});
}
let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() };
let mut item = Item::new(
name.to_string(),
ItemCore::Login(LoginCore {
username: if username.is_empty() { None } else { Some(username.to_string()) },
password: Some(Zeroizing::new(password.to_string())),
url: parsed_url,
totp: None,
}),
);
item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) };
Ok(item)
}
```
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<Item>, Option<ImportWarning>)`**
A row can produce both an item AND a warning (e.g., login imports without TOTP because the base32 was bad). The current `Result<Item, ImportWarning>` 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<Vec<u8>> {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
if upper.is_empty() { return None; }
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for ch in upper.bytes() {
let idx = ALPHA.iter().position(|&a| a == ch)?;
buffer = (buffer << 5) | (idx as u32);
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((buffer >> bits) & 0xFF) as u8);
}
}
Some(out)
}
```
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<Item>, Option<ImportWarning>) {
let url = record.get(0).unwrap_or("").trim();
let username = record.get(1).unwrap_or("").trim();
let password = record.get(2).unwrap_or("");
let totp_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<ImportWarning> = 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<Item>, Option<ImportWarning>)
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<ImportWarning> = 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<ImportWarning> = 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<ImportWarning> = 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/<id>.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<String> = 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/<id>.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 111. 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<String, JsError> {
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<Response, { ok: true }> {
data: {
items: Item[];
warnings: Array<{ row: number; title?: string; message: string }>;
};
}
export interface ImportLastPassCommitResponse extends Extract<Response, { ok: true }> {
data: {
summary: { itemCount: number };
};
}
```
- [ ] **Step 2: Verify type-check**
Run: `cd extension && bunx 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 && bunx 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<typeof import('../vault')>();
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<typeof vi.fn>).mockClear();
(vault.encryptAndWriteManifest as ReturnType<typeof vi.fn>).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 && bun run 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 && bun run 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 = `
<div class="panel">
<h1>Import</h1>
<section class="card" id="import-card">
<h2>LastPass CSV</h2>
<p>Pick the CSV exported from LastPass
(<code>More options → Advanced → Export</code>).
Items are added to the unlocked vault with fresh IDs.
Title collisions are kept — no dedup. Failed rows are
skipped and reported below.</p>
<input type="file" id="lp-file" accept=".csv,text/csv">
<div id="lp-preview" class="hidden">
<p id="lp-preview-text"></p>
<button id="lp-confirm-btn">Import…</button>
<button id="lp-cancel-btn">Cancel</button>
</div>
<div id="lp-progress" class="hidden">
<p id="lp-progress-text"></p>
</div>
<div id="lp-warnings" class="hidden">
<h3>Warnings</h3>
<ul id="lp-warning-list"></ul>
</div>
</section>
</div>
`;
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 `<li>${head}: ${escapeText(w.message)}</li>`;
})
.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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
<div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div>
<div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore →</button>
</div>
</div>
```
with:
```html
<div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div>
<div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore →</button>
</div>
</div>
<div class="settings-section">
<div class="settings-section__title">import</div>
<div class="settings-row">
<button class="btn" id="open-import">LastPass CSV →</button>
</div>
</div>
```
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 && bunx tsc --noEmit && bun run 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<typeof vi.fn>;
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<void> {
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 = '<div id="app"></div>';
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 && bun run 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 && bun run 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 <csv>`
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 && bun run 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 && bun run 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 <csv>\` 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 D10D13 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<String>`; 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`.