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

90 KiB
Raw Blame History

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.rspub mod import_lastpass + re-exports.
  • crates/relicario-core/src/error.rs — new variants (ImportCsvHeader, ImportCsvFormat).
  • crates/relicario-cli/src/main.rsImport 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.rsparse_lastpass_csv_json export.
  • extension/src/shared/messages.tsparse_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.mdUnreleased entry.

Pre-flight check

Run before starting Task 1:

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"):

csv = "1"
  • Step 2: Add error variants

Open crates/relicario-core/src/error.rs. Insert these variants after BackupSchemaMismatch (around line 52, before ItemNotFound):

    /// 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:

    #[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
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:

//! 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:

//! 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):

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
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:

#[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:

    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
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:

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:

/// 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:

/// 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:

        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
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:

#[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:

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:

    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
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:

#[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:

    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
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:

    /// Import items from another password manager into the unlocked vault.
    Import {
        #[command(subcommand)]
        action: ImportAction,
    },

After the BackupAction enum (around line 310), append:

#[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:

        Commands::Import { action } => cmd_import(action),

Add a stub cmd_import function above cmd_init:

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
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:

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:

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
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:

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:

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
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):

    #[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):

// ── 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
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:

  | { 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'):

  'parse_lastpass_csv', 'import_lastpass_commit',

After RestoreBackupResponse (around line 170-174), append:

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
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:

    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
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:

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
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):

// --- 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
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:

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':

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:

    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:

import { renderImportPanel, teardown as teardownImport } from './components/import-panel';

In teardownPaneComponents (around line 419-424), call the new teardown:

function teardownPaneComponents(): void {
  teardownTrash();
  teardownDevices();
  teardownFieldHistory();
  teardownBackup();
  teardownImport();
}

In the renderPane switch (around line 438-477), after case 'backup': add:

    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:

        <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:

        <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:

    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
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:

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
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):

- **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
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.