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>
2560 lines
90 KiB
Markdown
2560 lines
90 KiB
Markdown
# 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` — D10–D13, the LastPass field-mapping table, and the import data flow diagram.
|
||
|
||
**Sibling plan:** Plan 3A (`docs/superpowers/plans/2026-04-27-relicario-backup-restore.md`) shipped backup & restore. v0.3.0 ships when both 3A and 3B are merged and the pre-v0.3.0 audit checklist (`docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md`) is walked.
|
||
|
||
---
|
||
|
||
## Scope decisions (locked in by this plan)
|
||
|
||
These are calls made on top of the spec — record them so the executor doesn't re-litigate them mid-task.
|
||
|
||
- **CLI is single-commit, extension is multi-commit.** D13 ("ONE git commit covering all newly written items + manifest") is achievable from the CLI because `commit_paths` shells out to `git add` once and `git commit` once. The extension's `GitHost.writeFile` does one commit per file as a hard architectural constraint (same as existing `add_item`). For the extension, each item lands in its own commit and the manifest update lands in a final commit. The user-facing summary text is the same in both surfaces; the difference is only visible in the underlying git log.
|
||
- **Two SW message types, one panel.** The vault-tab Import panel needs (1) a parse-only round-trip to populate the preview and (2) a separate commit message that takes the (possibly-edited) item list and writes it. This avoids round-tripping the full preview structure twice and matches the spec's preview → confirm flow ("142 logins, 17 notes, 3 skipped — proceed?"). Names: `parse_lastpass_csv` (parse only, no vault state needed) and `import_lastpass_commit` (requires unlock, writes items + manifest).
|
||
- **`Item.notes` is preserved verbatim for non-SecureNote rows.** The spec says "`extra` (when `url != http://sn`) → `Item.notes`". LastPass packs structured login metadata (one-time URLs, hint text, etc.) into the same `extra` column for ordinary logins; we preserve it as-is on `Item.notes`. No attempt to parse it further.
|
||
- **Import does not touch the trash, devices, or settings.** It is purely additive: new items, manifest update, one git commit. The vault must already be initialized; "import into an empty repo" is a separate flow (`init` + `import`).
|
||
- **Preview UI shows aggregate counts only**, not per-row detail. Reasoning: a 1000-row CSV would render an unusable list; the user's decision is "proceed or cancel," not "review each row." Warnings list (skipped rows) is shown after the import completes, not in the preview.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
**Created:**
|
||
- `crates/relicario-core/src/import_lastpass.rs` — module with `parse_lastpass_csv`, `ImportWarning`, field-mapping logic.
|
||
- `crates/relicario-core/tests/import_lastpass.rs` — parser unit-test coverage.
|
||
- `crates/relicario-cli/tests/import_lastpass.rs` — end-to-end via `TestVault`.
|
||
- `crates/relicario-cli/tests/fixtures/lastpass-sample.csv` — synthesized sample, ~15 rows, no real credentials.
|
||
- `extension/src/vault/components/import-panel.ts` — vault-tab Import UI.
|
||
- `extension/src/vault/components/__tests__/import-panel.test.ts` — vitest.
|
||
- `extension/src/service-worker/__tests__/import.test.ts` — SW handler unit tests.
|
||
|
||
**Modified:**
|
||
- `crates/relicario-core/Cargo.toml` — add `csv = "1"`.
|
||
- `crates/relicario-core/src/lib.rs` — `pub mod import_lastpass` + re-exports.
|
||
- `crates/relicario-core/src/error.rs` — new variants (`ImportCsvHeader`, `ImportCsvFormat`).
|
||
- `crates/relicario-cli/src/main.rs` — `Import` clap variant + `cmd_import` / `cmd_import_lastpass` handlers.
|
||
- `crates/relicario-cli/tests/common/mod.rs` — (no changes anticipated; existing `TestVault` covers it — flagged here only so executors who hit a missing helper know where to add it).
|
||
- `crates/relicario-wasm/src/lib.rs` — `parse_lastpass_csv_json` export.
|
||
- `extension/src/shared/messages.ts` — `parse_lastpass_csv`, `import_lastpass_commit` request types.
|
||
- `extension/src/service-worker/router/popup-only.ts` — handler arms.
|
||
- `extension/src/service-worker/router/__tests__/router.test.ts` — sender matrix for the new types.
|
||
- `extension/src/vault/vault.ts` — register the panel + add hash route `#import`.
|
||
- `extension/src/popup/components/settings-vault.ts` — add "Import →" button next to "Backup & restore →"; deep-links the vault tab to `#import`.
|
||
- `CHANGELOG.md` — `Unreleased` entry.
|
||
|
||
---
|
||
|
||
## Pre-flight check
|
||
|
||
Run before starting Task 1:
|
||
|
||
```bash
|
||
cargo build && cargo test
|
||
cd extension && 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
|
||
//! (D10–D13 + the LastPass field-mapping table).
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use url::Url;
|
||
use zeroize::Zeroizing;
|
||
|
||
use crate::error::{RelicarioError, Result};
|
||
use crate::item::Item;
|
||
use crate::item_types::{ItemCore, LoginCore};
|
||
|
||
/// LastPass column order. The header row must contain these exact column
|
||
/// names in this exact order.
|
||
pub const EXPECTED_HEADER: &[&str] =
|
||
&["url", "username", "password", "totp", "extra", "name", "grouping", "fav"];
|
||
|
||
/// A row that was skipped, or partially imported with a downgrade
|
||
/// (e.g., login imported without TOTP).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ImportWarning {
|
||
/// 1-indexed row number in the CSV body (the header is row 0).
|
||
pub row: usize,
|
||
/// Title from the row's `name` column, if present and non-empty.
|
||
pub title: Option<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 1–11. Successful imports: rows 1, 2, 3, 4 (login, no TOTP, with warning), 5 (note), 6 (note), 7, 8 (login no URL, with warning), 11. That's 9 items. Skipped: rows 9 (no name), 10 (no password). Warnings: bad TOTP (row 4), missing name (row 9), missing password (row 10), bad URL (row 8). That's 4 warnings.
|
||
|
||
- [ ] **Step 2: Write the integration test**
|
||
|
||
Create `crates/relicario-cli/tests/import_lastpass.rs`:
|
||
|
||
```rust
|
||
mod common;
|
||
use common::TestVault;
|
||
|
||
const FIXTURE: &str = "tests/fixtures/lastpass-sample.csv";
|
||
|
||
fn fixture_path() -> std::path::PathBuf {
|
||
// Manifest dir = crates/relicario-cli; the fixture is relative to it.
|
||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE)
|
||
}
|
||
|
||
#[test]
|
||
fn imports_logins_secure_notes_and_warns_on_skipped() {
|
||
let v = TestVault::init();
|
||
|
||
let out = v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||
assert!(
|
||
out.status.success(),
|
||
"import failed:\nstdout: {}\nstderr: {}",
|
||
String::from_utf8_lossy(&out.stdout),
|
||
String::from_utf8_lossy(&out.stderr),
|
||
);
|
||
|
||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||
// 9 items expected (see fixture comment).
|
||
assert!(stderr.contains("Imported 9"), "stderr: {stderr}");
|
||
assert!(stderr.contains("skipped 2"), "stderr: {stderr}");
|
||
|
||
// Each warning surfaces.
|
||
assert!(stderr.contains("invalid base32 TOTP"), "TOTP warning missing");
|
||
assert!(stderr.contains("invalid URL"), "URL warning missing");
|
||
assert!(stderr.contains("missing `name`"), "name-missing warning missing");
|
||
assert!(stderr.contains("missing `password`"), "password-missing warning missing");
|
||
}
|
||
|
||
#[test]
|
||
fn list_after_import_shows_imported_titles() {
|
||
let v = TestVault::init();
|
||
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||
|
||
let out = v.run(&["list"]);
|
||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||
assert!(stdout.contains("GitHub"));
|
||
assert!(stdout.contains("Gmail"));
|
||
assert!(stdout.contains("Home Wifi"));
|
||
assert!(stdout.contains("Visa Card"));
|
||
assert!(stdout.contains("日本語サイト"));
|
||
// Skipped rows must NOT appear.
|
||
assert!(!stdout.contains("No Password"),
|
||
"row with no password should have been skipped");
|
||
}
|
||
|
||
#[test]
|
||
fn import_creates_a_single_git_commit() {
|
||
let v = TestVault::init();
|
||
|
||
// Count commits before.
|
||
let before = std::process::Command::new("git")
|
||
.arg("-C").arg(v.path())
|
||
.args(["rev-list", "--count", "HEAD"])
|
||
.output().unwrap();
|
||
let before_n: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||
|
||
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||
|
||
let after = std::process::Command::new("git")
|
||
.arg("-C").arg(v.path())
|
||
.args(["rev-list", "--count", "HEAD"])
|
||
.output().unwrap();
|
||
let after_n: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||
|
||
assert_eq!(after_n, before_n + 1, "expected exactly one new commit");
|
||
|
||
// Commit message includes the count + "LastPass".
|
||
let log = std::process::Command::new("git")
|
||
.arg("-C").arg(v.path())
|
||
.args(["log", "-1", "--pretty=%s"])
|
||
.output().unwrap();
|
||
let subject = String::from_utf8(log.stdout).unwrap();
|
||
assert!(subject.contains("9 items"));
|
||
assert!(subject.contains("LastPass"));
|
||
}
|
||
|
||
#[test]
|
||
fn import_with_zero_items_exits_nonzero() {
|
||
let v = TestVault::init();
|
||
|
||
// Header-only CSV with one bad row → 0 items.
|
||
let bad_csv = v.path().join("empty.csv");
|
||
std::fs::write(
|
||
&bad_csv,
|
||
"url,username,password,totp,extra,name,grouping,fav\n,,,,,,,\n",
|
||
).unwrap();
|
||
|
||
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
|
||
assert!(!out.status.success(), "expected non-zero exit on zero items");
|
||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||
assert!(stderr.contains("imported 0 items"), "stderr: {stderr}");
|
||
}
|
||
|
||
#[test]
|
||
fn import_rejects_unrecognized_header() {
|
||
let v = TestVault::init();
|
||
let bad_csv = v.path().join("wrong.csv");
|
||
std::fs::write(&bad_csv, "name,url,user,pass\nA,https://x,u,p\n").unwrap();
|
||
|
||
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
|
||
assert!(!out.status.success());
|
||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||
assert!(
|
||
stderr.contains("LastPass") || stderr.contains("expected"),
|
||
"stderr: {stderr}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn imported_items_keep_unique_ids_across_runs() {
|
||
// Decision D12: two imports of the same CSV must not collide.
|
||
let v = TestVault::init();
|
||
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||
|
||
let out = v.run(&["list"]);
|
||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||
// Each title imported twice — count occurrences of "GitHub" must be 2.
|
||
let github_count = stdout.matches("GitHub").count();
|
||
assert_eq!(github_count, 2, "stdout: {stdout}");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run the tests**
|
||
|
||
Run: `cargo test -p relicario-cli --test import_lastpass`
|
||
Expected: PASS — all 6 tests green.
|
||
|
||
- [ ] **Step 4: Run the full test suite to confirm no regressions**
|
||
|
||
Run: `cargo test`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/relicario-cli/tests/import_lastpass.rs \
|
||
crates/relicario-cli/tests/fixtures/lastpass-sample.csv
|
||
git commit -m "test(cli): integration coverage for \`import lastpass\`
|
||
|
||
Fixture CSV exercises 11 rows: standard login, login + TOTP,
|
||
SecureNote (plain + structured), unicode title, bad URL,
|
||
malformed rows. Tests verify item count, single git commit,
|
||
warning surface area, exit code, and ID uniqueness across
|
||
back-to-back imports."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: WASM — `parse_lastpass_csv_json` export
|
||
|
||
**Files:**
|
||
- Modify: `crates/relicario-wasm/src/lib.rs`
|
||
|
||
Thin JSON-encoding wrapper around `core::parse_lastpass_csv`. The SW receives the file bytes (as a `Uint8Array`), calls this, and gets back JSON for the preview UI. Encryption + commit is a separate path (still on the SW side, using the existing `item_encrypt` / `manifest_encrypt` bridges).
|
||
|
||
- [ ] **Step 1: Write a native test for the bridge**
|
||
|
||
Append to the existing `#[cfg(test)] mod session_tests` block at the bottom of `crates/relicario-wasm/src/lib.rs` (alongside `manifest_round_trip_via_handle`):
|
||
|
||
```rust
|
||
#[test]
|
||
fn parse_lastpass_csv_json_returns_items_and_warnings() {
|
||
// Row 1 imports cleanly; row 2 has an empty `name` and is skipped
|
||
// with a warning.
|
||
let csv = "url,username,password,totp,extra,name,grouping,fav\n\
|
||
https://x,alice,hunter2,,,GitHub,Work,1\n\
|
||
https://y,bob,hunter2,,,,,";
|
||
let json = super::parse_lastpass_csv_json(csv.as_bytes()).unwrap();
|
||
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(v["items"].as_array().unwrap().len(), 1);
|
||
assert_eq!(v["warnings"].as_array().unwrap().len(), 1);
|
||
assert!(v["warnings"][0]["message"].as_str().unwrap().contains("name"));
|
||
// The item's title round-trips as a plain JSON string.
|
||
assert_eq!(v["items"][0]["title"].as_str().unwrap(), "GitHub");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_lastpass_csv_json_propagates_header_errors() {
|
||
let bad = "name,user,pass\nA,u,p\n";
|
||
let err = super::parse_lastpass_csv_json(bad.as_bytes()).unwrap_err();
|
||
// JsError surfaces through Display via the inner error.
|
||
let _ = err; // smoke-only — JsError doesn't expose its message in a native test.
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cargo test -p relicario-wasm session_tests`
|
||
Expected: FAIL on missing `parse_lastpass_csv_json` function.
|
||
|
||
- [ ] **Step 3: Implement the bridge**
|
||
|
||
Append at the end of `crates/relicario-wasm/src/lib.rs` (after the `// ── Backup container bridge ──...` block, before the `#[cfg(test)] mod session_tests`):
|
||
|
||
```rust
|
||
// ── LastPass CSV importer bridge ────────────────────────────────────────────
|
||
|
||
use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv;
|
||
|
||
/// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`.
|
||
///
|
||
/// Items are returned as full `Item` JSON objects with freshly-minted IDs
|
||
/// and timestamps already populated. The SW caller is responsible for
|
||
/// encrypting + writing them; this bridge stays pure so the preview UI
|
||
/// can render counts without committing anything.
|
||
#[wasm_bindgen]
|
||
pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
export function teardown(): void {
|
||
preview = null;
|
||
mode = 'idle';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the panel into the vault tab**
|
||
|
||
Edit `extension/src/vault/vault.ts`:
|
||
|
||
In the `VaultView` union (around line 70), add `'import'`:
|
||
|
||
```ts
|
||
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup' | 'import';
|
||
```
|
||
|
||
In the `parseHash` switch (around line 85-100), add `'import'` to the no-arg cases:
|
||
|
||
```ts
|
||
case 'trash':
|
||
case 'devices':
|
||
case 'settings':
|
||
case 'settings-vault':
|
||
case 'field-history':
|
||
case 'backup':
|
||
case 'import':
|
||
return { view };
|
||
```
|
||
|
||
At the top of the file (around line 19), add the import:
|
||
|
||
```ts
|
||
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||
```
|
||
|
||
In `teardownPaneComponents` (around line 419-424), call the new teardown:
|
||
|
||
```ts
|
||
function teardownPaneComponents(): void {
|
||
teardownTrash();
|
||
teardownDevices();
|
||
teardownFieldHistory();
|
||
teardownBackup();
|
||
teardownImport();
|
||
}
|
||
```
|
||
|
||
In the `renderPane` switch (around line 438-477), after `case 'backup':` add:
|
||
|
||
```ts
|
||
case 'import':
|
||
renderImportPanel(pane);
|
||
break;
|
||
```
|
||
|
||
- [ ] **Step 3: Add the popup deep-link button**
|
||
|
||
Edit `extension/src/popup/components/settings-vault.ts`. Around line 161-166 (the existing "backup & restore" section), add a sibling section right after it. Replace this block:
|
||
|
||
```html
|
||
<div class="settings-section">
|
||
<div class="settings-section__title">backup & restore</div>
|
||
<div class="settings-row">
|
||
<button class="btn" id="open-backup">Backup & restore →</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
with:
|
||
|
||
```html
|
||
<div class="settings-section">
|
||
<div class="settings-section__title">backup & restore</div>
|
||
<div class="settings-row">
|
||
<button class="btn" id="open-backup">Backup & 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 D10–D13 decision and every row of the LastPass field-mapping table is exercised by a test:
|
||
- D10 ("LastPass CSV → Login + SecureNote") — Tasks 2, 5
|
||
- D11 ("Failed rows skipped, exit 0 if any imported") — Tasks 6, 9
|
||
- D12 ("Title collisions kept, fresh IDs") — Tasks 2, 9
|
||
- D13 ("Single git commit") — Task 9 (CLI), Task 12 scope-decision note (extension)
|
||
- Field mapping: name/grouping/fav (Task 3), url/username/password (Task 2), totp (Task 4), extra (Tasks 3, 5)
|
||
2. **Atomicity claims.** Manifest is written last in both surfaces; orphan item files in the SW path between per-item commits don't pollute the manifest because the SW updates `state.manifest.items` in memory and only flushes once.
|
||
3. **No placeholders.** Search the diff for `TODO`, `unimplemented!`, `bail!("not implemented")`, `// fill in`. The Task 7 stub bail is replaced in Task 8; no other placeholders should remain.
|
||
4. **API consistency.** The WASM `parse_lastpass_csv_json` returns `{items, warnings}`. The SW `parse_lastpass_csv` response wraps it in `{ ok: true, data: { items, warnings } }`. The vault tab destructures `data.items` and `data.warnings`. Verify the field names match end-to-end.
|
||
5. **Memory hygiene.** `LoginCore.password` is `Zeroizing<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`.
|