From 768f0d39a51267641c9e3edbc69bd17fee2f7c5f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 22:47:06 -0400 Subject: [PATCH 01/18] 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. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 28 ++++++++++++++++++++++++++++ crates/relicario-core/Cargo.toml | 1 + crates/relicario-core/src/error.rs | 22 ++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 2d9d24e..19c769d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,6 +437,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1609,6 +1630,7 @@ dependencies = [ "bip39", "chacha20poly1305", "chrono", + "csv", "ed25519-dalek", "getrandom 0.2.17", "hex", @@ -1696,6 +1718,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index 8456073..2eb1a32 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -29,5 +29,6 @@ getrandom = "0.2" zstd = { version = "0.13", default-features = false } tar = { version = "0.4", default-features = false } base64 = "0.22" +csv = "1" [dev-dependencies] diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index 5d75a82..8fb19bf 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -51,6 +51,17 @@ pub enum RelicarioError { #[error("backup envelope schema v{found}; this relicario reads v{expected}")] BackupSchemaMismatch { found: u32, expected: u32 }, + /// 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), + /// An item was looked up by ID but does not exist in the manifest. #[error("item not found: {0}")] ItemNotFound(String), @@ -156,4 +167,15 @@ mod tests { let s = format!("{}", schema); assert!(s.contains("v2") && s.contains("v1")); } + + #[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")); + } } From 9ee876cc4b17924fccffa41dadf7c4a3fc816809 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 22:52:20 -0400 Subject: [PATCH 02/18] =?UTF-8?q?feat(core):=20import=5Flastpass=20parser?= =?UTF-8?q?=20=E2=80=94=20happy-path=20Login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-core/src/import_lastpass.rs | 129 ++++++++++++++++++ crates/relicario-core/src/lib.rs | 3 + .../relicario-core/tests/import_lastpass.rs | 46 +++++++ 3 files changed, 178 insertions(+) create mode 100644 crates/relicario-core/src/import_lastpass.rs create mode 100644 crates/relicario-core/tests/import_lastpass.rs diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs new file mode 100644 index 0000000..e7a2204 --- /dev/null +++ b/crates/relicario-core/src/import_lastpass.rs @@ -0,0 +1,129 @@ +//! LastPass CSV importer. +//! +//! Pure: takes CSV bytes, returns a vector of `Item` (with freshly-minted +//! IDs and timestamps) plus a vector of `ImportWarning` for skipped or +//! partially-imported rows. Failed rows never abort the whole import; +//! the only fatal error is a missing or malformed header. +//! +//! Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md +//! (D10–D13 + the LastPass field-mapping table). + +use serde::{Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use crate::error::{RelicarioError, Result}; +use crate::item::Item; +use crate::item_types::{ItemCore, LoginCore}; + +/// LastPass column order. The header row must contain these exact column +/// names in this exact order. +pub const EXPECTED_HEADER: &[&str] = + &["url", "username", "password", "totp", "extra", "name", "grouping", "fav"]; + +/// A row that was skipped, or partially imported with a downgrade +/// (e.g., login imported without TOTP). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportWarning { + /// 1-indexed row number in the CSV body (the header is row 0). + pub row: usize, + /// Title from the row's `name` column, if present and non-empty. + pub title: Option, + /// Human-readable explanation, suitable for stderr / inline UI. + pub message: String, +} + +/// Parse a LastPass CSV export. +/// +/// Returns the parsed items (with fresh IDs and timestamps) and any +/// per-row warnings. The function only fails if the header is missing +/// or doesn't match `EXPECTED_HEADER`. +pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec, Vec)> { + let mut reader = csv::ReaderBuilder::new() + .has_headers(true) + .flexible(false) + .from_reader(csv_bytes); + + // Validate header. + let headers = reader + .headers() + .map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))? + .clone(); + if headers.len() != EXPECTED_HEADER.len() + || headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want) + { + return Err(RelicarioError::ImportCsvHeader(format!( + "expected `{}`, got `{}`", + EXPECTED_HEADER.join(","), + headers.iter().collect::>().join(",") + ))); + } + + let mut items = Vec::new(); + let mut warnings = Vec::new(); + + for (idx, record) in reader.records().enumerate() { + let row_num = idx + 1; + let record = match record { + Ok(r) => r, + Err(e) => { + warnings.push(ImportWarning { + row: row_num, + title: None, + message: format!("CSV parse error — skipped: {e}"), + }); + continue; + } + }; + + match map_row(&record, row_num) { + Ok(item) => items.push(item), + Err(w) => warnings.push(w), + } + } + + Ok((items, warnings)) +} + +/// Map a single CSV record to either an `Item` or an `ImportWarning`. +/// Column order is fixed by `EXPECTED_HEADER`. +fn map_row(record: &csv::StringRecord, row: usize) -> std::result::Result { + let url = record.get(0).unwrap_or("").trim(); + let username = record.get(1).unwrap_or("").trim(); + let password = record.get(2).unwrap_or(""); + let _totp = record.get(3).unwrap_or(""); // populated in Task 4 + let extra = record.get(4).unwrap_or(""); + let name = record.get(5).unwrap_or("").trim(); + let _group = record.get(6).unwrap_or("").trim(); // populated in Task 3 + let _fav = record.get(7).unwrap_or("").trim(); // populated in Task 3 + + if name.is_empty() { + return Err(ImportWarning { + row, + title: None, + message: "missing `name` — skipped".into(), + }); + } + + if password.is_empty() { + return Err(ImportWarning { + row, + title: Some(name.to_string()), + message: "missing `password` — skipped".into(), + }); + } + + let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; + + let mut item = Item::new( + name.to_string(), + ItemCore::Login(LoginCore { + username: if username.is_empty() { None } else { Some(username.to_string()) }, + password: Some(Zeroizing::new(password.to_string())), + url: parsed_url, + totp: None, + }), + ); + item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) }; + Ok(item) +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 1fedf8d..d5bb99f 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -80,3 +80,6 @@ pub mod imgsecret; pub mod backup; pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment}; + +pub mod import_lastpass; +pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; diff --git a/crates/relicario-core/tests/import_lastpass.rs b/crates/relicario-core/tests/import_lastpass.rs new file mode 100644 index 0000000..7cc14db --- /dev/null +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -0,0 +1,46 @@ +//! 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() +} From 16888d5a3aa9623f1ee41aba291d656e6b233c6f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 22:57:37 -0400 Subject: [PATCH 03/18] =?UTF-8?q?feat(core):=20import=5Flastpass=20?= =?UTF-8?q?=E2=80=94=20group,=20favorite,=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map LastPass grouping/fav/extra columns to relicario item metadata. Grouping becomes item.group, fav="1" sets item.favorite, extra becomes item.notes. Multi-line extra via CSV quoting round-trips correctly. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-core/src/import_lastpass.rs | 8 +-- .../relicario-core/tests/import_lastpass.rs | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index e7a2204..2385553 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -94,8 +94,8 @@ fn map_row(record: &csv::StringRecord, row: usize) -> std::result::Result std::result::Result String { warnings.first().expect("expected at least one warning").message.clone() } + +#[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")); +} From c4905c5ee76c56893552640f58d588c9ef8e0620 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:02:16 -0400 Subject: [PATCH 04/18] =?UTF-8?q?feat(core):=20import=5Flastpass=20?= =?UTF-8?q?=E2=80=94=20TOTP=20base32=20=E2=86=92=20TotpConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Successful base32 decode attaches a SHA1/6/30s Totp config to LoginCore.totp. Bad base32 emits a warning and imports the login without TOTP rather than skipping the row entirely. Refactors map_row to return (Option, Option) so a single row can produce both an item and a warning. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-core/src/import_lastpass.rs | 80 +++++++++++++++---- .../relicario-core/tests/import_lastpass.rs | 57 +++++++++++++ 2 files changed, 123 insertions(+), 14 deletions(-) diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index 2385553..696b692 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -76,56 +76,108 @@ pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec, Vec items.push(item), - Err(w) => warnings.push(w), - } + let (item, warn) = map_row(&record, row_num); + if let Some(it) = item { items.push(it); } + if let Some(w) = warn { warnings.push(w); } } Ok((items, warnings)) } -/// Map a single CSV record 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 { +/// Map a single CSV record. Returns: +/// - `(Some(item), None)` for a fully-imported row. +/// - `(Some(item), Some(warn))` for a partially-imported row (e.g., +/// bad TOTP base32 — login imported without TOTP). +/// - `(None, Some(warn))` for a skipped row (missing required field). +fn map_row( + record: &csv::StringRecord, + row: usize, +) -> (Option, Option) { let url = record.get(0).unwrap_or("").trim(); let username = record.get(1).unwrap_or("").trim(); let password = record.get(2).unwrap_or(""); - let _totp = record.get(3).unwrap_or(""); // populated in Task 4 + 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 Err(ImportWarning { + return (None, Some(ImportWarning { row, title: None, message: "missing `name` — skipped".into(), - }); + })); } if password.is_empty() { - return Err(ImportWarning { + return (None, Some(ImportWarning { row, title: Some(name.to_string()), message: "missing `password` — skipped".into(), - }); + })); } let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; + let mut warning: Option = None; + let totp = if totp_raw.is_empty() { + None + } else { + match decode_base32_totp(totp_raw) { + Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { + secret: Zeroizing::new(bytes), + algorithm: crate::item_types::TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: crate::item_types::TotpKind::Totp, + }), + _ => { + warning = Some(ImportWarning { + row, + title: Some(name.to_string()), + message: "invalid base32 TOTP secret — login imported without TOTP".into(), + }); + None + } + } + }; + let mut item = Item::new( name.to_string(), ItemCore::Login(LoginCore { username: if username.is_empty() { None } else { Some(username.to_string()) }, password: Some(Zeroizing::new(password.to_string())), url: parsed_url, - totp: None, + 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()) }; - Ok(item) + + (Some(item), warning) +} + +/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive, +/// padding optional. Returns None if the input contains any non-alphabet +/// character (after upper-casing). Used by the LastPass importer. +fn decode_base32_totp(secret: &str) -> Option> { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase(); + if upper.is_empty() { return None; } + + let mut out = Vec::with_capacity(upper.len() * 5 / 8); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for ch in upper.bytes() { + let idx = ALPHA.iter().position(|&a| a == ch)?; + buffer = (buffer << 5) | (idx as u32); + bits += 5; + if bits >= 8 { + bits -= 8; + out.push(((buffer >> bits) & 0xFF) as u8); + } + } + Some(out) } diff --git a/crates/relicario-core/tests/import_lastpass.rs b/crates/relicario-core/tests/import_lastpass.rs index f79c0be..2d20e74 100644 --- a/crates/relicario-core/tests/import_lastpass.rs +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -1,6 +1,7 @@ //! LastPass CSV importer — parser coverage. use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning}; +use relicario_core::item_types::{TotpAlgorithm, TotpKind}; use relicario_core::ItemCore; const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav"; @@ -98,3 +99,59 @@ fn multiline_extra_round_trips_via_quoting() { assert!(warnings.is_empty(), "multi-line extra should parse cleanly"); assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3")); } + +#[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!(), + } +} From 0841bddcb57806e273f86fd58264f021365a6767 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:06:03 -0400 Subject: [PATCH 05/18] =?UTF-8?q?feat(core):=20import=5Flastpass=20?= =?UTF-8?q?=E2=80=94=20SecureNote=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-core/src/import_lastpass.rs | 16 +++++- .../relicario-core/tests/import_lastpass.rs | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index 696b692..e206100 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -14,7 +14,7 @@ use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; use crate::item::Item; -use crate::item_types::{ItemCore, LoginCore}; +use crate::item_types::{ItemCore, LoginCore, SecureNoteCore}; /// LastPass column order. The header row must contain these exact column /// names in this exact order. @@ -110,6 +110,20 @@ fn map_row( })); } + // 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, diff --git a/crates/relicario-core/tests/import_lastpass.rs b/crates/relicario-core/tests/import_lastpass.rs index 2d20e74..c1cf39b 100644 --- a/crates/relicario-core/tests/import_lastpass.rs +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -155,3 +155,55 @@ fn login_with_lowercase_base32_totp_is_accepted() { _ => unreachable!(), } } + +#[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!(), + } +} From 6f2e8688929996b2be2f6a378845eefd5bfb7770 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:09:23 -0400 Subject: [PATCH 06/18] =?UTF-8?q?feat(core):=20import=5Flastpass=20?= =?UTF-8?q?=E2=80=94=20URL/header=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/relicario-core/src/import_lastpass.rs | 35 ++++++++-- .../relicario-core/tests/import_lastpass.rs | 67 +++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index e206100..5916b34 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -132,9 +132,27 @@ fn map_row( })); } - let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() }; - let mut warning: Option = None; + + let parsed_url = if url.is_empty() { + None + } else { + match Url::parse(url) { + Ok(u) => Some(u), + Err(_) => { + // Login still imports — URL becomes None, with a warning. + if warning.is_none() { + warning = Some(ImportWarning { + row, + title: Some(name.to_string()), + message: format!("invalid URL `{url}` — login imported without URL"), + }); + } + None + } + } + }; + let totp = if totp_raw.is_empty() { None } else { @@ -147,11 +165,14 @@ fn map_row( 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(), - }); + if warning.is_none() { + warning = Some(ImportWarning { + row, + title: Some(name.to_string()), + message: "invalid base32 TOTP secret — login imported without TOTP" + .into(), + }); + } None } } diff --git a/crates/relicario-core/tests/import_lastpass.rs b/crates/relicario-core/tests/import_lastpass.rs index c1cf39b..7cda219 100644 --- a/crates/relicario-core/tests/import_lastpass.rs +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -207,3 +207,70 @@ fn secure_note_preserves_structured_extra_verbatim() { _ => unreachable!(), } } + +#[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}"); +} From ab8839a46a6be09a5ae5bf92f45ddee5af3800cb Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:12:44 -0400 Subject: [PATCH 07/18] 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. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-cli/src/main.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 54009ed..0244dee 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -100,6 +100,12 @@ enum Commands { action: BackupAction, }, + /// Import items from another password manager into the unlocked vault. + Import { + #[command(subcommand)] + action: ImportAction, + }, + /// Attach a file to an item. Attach { query: String, file: PathBuf }, @@ -309,6 +315,18 @@ enum BackupAction { }, } +#[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, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -323,6 +341,7 @@ fn main() -> Result<()> { Commands::Purge { query } => cmd_purge(query), Commands::Trash { action } => cmd_trash(action), Commands::Backup { action } => cmd_backup(action), + Commands::Import { action } => cmd_import(action), Commands::Attach { query, file } => cmd_attach(query, file), Commands::Attachments { query } => cmd_attachments(query), Commands::Extract { query, aid, out } => cmd_extract(query, aid, out), @@ -1539,6 +1558,16 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { Ok(()) } +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") +} + fn cmd_trash_empty() -> Result<()> { use relicario_core::time::now_unix; From 2fda9e0d50c231255780a3af50cd9c1bbb8bfeb6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:16:07 -0400 Subject: [PATCH 08/18] =?UTF-8?q?feat(cli):=20cmd=5Fimport=5Flastpass=20?= =?UTF-8?q?=E2=80=94=20full=20data=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlocks the vault, parses the CSV, encrypts each item, writes items/.enc and manifest.enc, then a single `git add … && git commit` covers all of them. Stderr progress every 50 items + final summary. Exit non-zero only when zero items imported. --- crates/relicario-cli/src/main.rs | 70 +++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 0244dee..e62e863 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1564,8 +1564,74 @@ fn cmd_import(action: ImportAction) -> Result<()> { } } -fn cmd_import_lastpass(_csv: PathBuf) -> Result<()> { - bail!("not implemented yet — Task 8 lands the body") +fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::import_lastpass::parse_lastpass_csv; + + let csv_bytes = fs::read(&csv_path) + .with_context(|| format!("failed to read CSV {}", csv_path.display()))?; + + let (items, warnings) = parse_lastpass_csv(&csv_bytes)?; + + if items.is_empty() { + // Print all warnings so the user sees why nothing imported. + for w in &warnings { + print_warning(w); + } + bail!( + "imported 0 items from {} — see warnings above", + csv_path.display() + ); + } + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + + let total = items.len(); + let mut written_paths: Vec = Vec::with_capacity(items.len() + 1); + + for (idx, item) in items.iter().enumerate() { + vault.save_item(item)?; + manifest.upsert(item); + written_paths.push(format!("items/{}.enc", item.id.as_str())); + + let n = idx + 1; + if n % 50 == 0 || n == total { + eprintln!("[{n}/{total}] importing..."); + } + } + + vault.save_manifest(&manifest)?; + written_paths.push("manifest.enc".into()); + + let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect(); + let csv_filename = csv_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("lastpass.csv"); + commit_paths( + &vault, + &format!("import: {} items from LastPass ({})", total, csv_filename), + &path_refs, + )?; + + for w in &warnings { + print_warning(w); + } + eprintln!( + "Imported {}, skipped {} (see warnings above)", + total, + warnings.iter().filter(|w| w.message.contains("skipped")).count() + ); + Ok(()) +} + +fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) { + let prefix = match &w.title { + Some(t) => format!("row {} ({}):", w.row, t), + None => format!("row {}:", w.row), + }; + eprintln!("warning: {prefix} {}", w.message); } fn cmd_trash_empty() -> Result<()> { From d6831fcfd8de54a4943f49aff79360604b8c51ac Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:22:54 -0400 Subject: [PATCH 09/18] 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. Co-Authored-By: Claude Opus 4.7 --- .../tests/fixtures/lastpass-sample.csv | 17 +++ crates/relicario-cli/tests/import_lastpass.rs | 127 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 crates/relicario-cli/tests/fixtures/lastpass-sample.csv create mode 100644 crates/relicario-cli/tests/import_lastpass.rs diff --git a/crates/relicario-cli/tests/fixtures/lastpass-sample.csv b/crates/relicario-cli/tests/fixtures/lastpass-sample.csv new file mode 100644 index 0000000..84fd156 --- /dev/null +++ b/crates/relicario-cli/tests/fixtures/lastpass-sample.csv @@ -0,0 +1,17 @@ +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,, diff --git a/crates/relicario-cli/tests/import_lastpass.rs b/crates/relicario-cli/tests/import_lastpass.rs new file mode 100644 index 0000000..843a161 --- /dev/null +++ b/crates/relicario-cli/tests/import_lastpass.rs @@ -0,0 +1,127 @@ +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}"); +} From 1f764a4639c322b07ebc7295b6c58073e5cb266b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:25:25 -0400 Subject: [PATCH 10/18] 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. Co-Authored-By: Claude Opus 4.7 --- crates/relicario-wasm/src/lib.rs | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 4657e58..c57c375 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -432,6 +432,28 @@ pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result Result { + let (items, warnings) = core_parse_lastpass_csv(csv_bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + + let json = serde_json::json!({ + "items": items, + "warnings": warnings, + }); + Ok(json.to_string()) +} + #[cfg(test)] mod session_tests { use super::*; @@ -474,4 +496,31 @@ mod session_tests { let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap(); assert_ne!(bytes, bytes2, "nonces must differ"); } + + #[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() { + // Test the underlying core function directly since native tests + // can't call wasm_bindgen functions. + use relicario_core::import_lastpass::parse_lastpass_csv; + let bad = "name,user,pass\nA,u,p\n"; + let err = parse_lastpass_csv(bad.as_bytes()); + // Should fail with a header validation error. + assert!(err.is_err()); + } } From fbd029e4cbcce6b6478d24c37a8c273a1ac7aa1c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:30:18 -0400 Subject: [PATCH 11/18] 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. Co-Authored-By: Claude Opus 4.7 --- extension/src/shared/messages.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 1788701..f5a73a7 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -55,7 +55,9 @@ export type PopupMessage = bytes: ArrayBuffer; passphrase: string; newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string }; - }; + } + | { type: 'parse_lastpass_csv'; bytes: ArrayBuffer } + | { type: 'import_lastpass_commit'; items: Item[] }; // --- Messages a content script may send --- @@ -161,6 +163,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'get_field_history', 'get_session_config', 'update_session_config', 'export_backup', 'restore_backup', + 'parse_lastpass_csv', 'import_lastpass_commit', ] as PopupMessage['type'][]); export interface ExportBackupResponse extends Extract { @@ -173,6 +176,19 @@ export interface RestoreBackupResponse extends Extract { }; } +export interface ParseLastPassCsvResponse extends Extract { + data: { + items: Item[]; + warnings: Array<{ row: number; title?: string; message: string }>; + }; +} + +export interface ImportLastPassCommitResponse extends Extract { + data: { + summary: { itemCount: number }; + }; +} + export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', 'capture_save_login', From b29a1384117aa2b8082013d6280934e72115a0f5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:30:26 -0400 Subject: [PATCH 12/18] feat(ext/sw): parse + commit handlers for LastPass import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.7 --- .../src/service-worker/router/popup-only.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 83a027d..15d3582 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -507,6 +507,52 @@ export async function handle( return { ok: false, error: (e as Error).message }; } } + + 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 }; + } + } } } From ecb137a120ab4b4928a47bea791b9471a734c05d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:33:16 -0400 Subject: [PATCH 13/18] test(ext/sw): unit tests for parse + commit handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Claude Opus 4.7 --- .../service-worker/__tests__/import.test.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 extension/src/service-worker/__tests__/import.test.ts diff --git a/extension/src/service-worker/__tests__/import.test.ts b/extension/src/service-worker/__tests__/import.test.ts new file mode 100644 index 0000000..3b6b418 --- /dev/null +++ b/extension/src/service-worker/__tests__/import.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fakeHost = { + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileCreateOnly: vi.fn(), + deleteFile: vi.fn(), + listDir: vi.fn(), + lastCommit: vi.fn(), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), +}; + +vi.mock('../session', () => ({ + setCurrent: vi.fn(), + getCurrent: vi.fn(() => ({ value: 1 })), + clearCurrent: vi.fn(), + requireCurrent: vi.fn(), +})); + +vi.mock('../vault', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + encryptAndWriteItem: vi.fn().mockResolvedValue(undefined), + encryptAndWriteManifest: vi.fn().mockResolvedValue(undefined), + }; +}); + +import { handle, type PopupState } from '../router/popup-only'; +import * as vault from '../vault'; +import type { Manifest, Item } from '../../shared/types'; + +const FAKE_SENDER = { + url: 'chrome-extension://x/vault.html', + id: 'x', + frameId: 0, +} as unknown as chrome.runtime.MessageSender; + +const EMPTY_MANIFEST: Manifest = { schema_version: 2, items: {} } as Manifest; + +function fakeWasm() { + let counter = 0; + return { + parse_lastpass_csv_json: vi.fn().mockReturnValue(JSON.stringify({ + items: [ + sampleItem('GitHub'), + sampleItem('Gmail'), + ], + warnings: [{ row: 3, title: 'No Pass', message: 'missing `password` — skipped' }], + })), + new_item_id: vi.fn(() => `newid${++counter}`.padEnd(16, '0')), + }; +} + +function sampleItem(title: string): Item { + return { + id: 'placeholder-id00', + title, + type: 'login', + tags: [], + favorite: false, + created: 1000, + modified: 1000, + core: { type: 'login', username: 'u', password: 'p' }, + sections: [], + attachments: [], + field_history: {}, + } as unknown as Item; +} + +describe('parse_lastpass_csv handler', () => { + beforeEach(() => { + (globalThis as { chrome?: unknown }).chrome = { + storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() } }, + }; + }); + + it('returns items + warnings from the WASM bridge', async () => { + const state: PopupState = { + manifest: EMPTY_MANIFEST, + gitHost: fakeHost as never, + wasm: fakeWasm(), + }; + const result = await handle( + { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) }, + state, + FAKE_SENDER, + ); + expect(result.ok).toBe(true); + if (result.ok) { + const data = result.data as { items: Item[]; warnings: unknown[] }; + expect(data.items).toHaveLength(2); + expect(data.warnings).toHaveLength(1); + } + }); + + it('surfaces WASM errors as ok:false', async () => { + const state: PopupState = { + manifest: EMPTY_MANIFEST, + gitHost: fakeHost as never, + wasm: { + parse_lastpass_csv_json: vi.fn(() => { throw new Error('bad header'); }), + }, + }; + const result = await handle( + { type: 'parse_lastpass_csv', bytes: new ArrayBuffer(0) }, + state, + FAKE_SENDER, + ); + expect(result).toEqual({ ok: false, error: 'bad header' }); + }); +}); + +describe('import_lastpass_commit handler', () => { + beforeEach(() => { + (vault.encryptAndWriteItem as ReturnType).mockClear(); + (vault.encryptAndWriteManifest as ReturnType).mockClear(); + (globalThis as { chrome?: unknown }).chrome = { + storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() } }, + }; + }); + + it('encrypts + writes each item, manifest last', async () => { + const state: PopupState = { + manifest: { ...EMPTY_MANIFEST, items: {} }, + gitHost: fakeHost as never, + wasm: fakeWasm(), + }; + + const result = await handle( + { + type: 'import_lastpass_commit', + items: [sampleItem('A'), sampleItem('B'), sampleItem('C')], + }, + state, + FAKE_SENDER, + ); + expect(result.ok).toBe(true); + if (result.ok) { + const data = result.data as { summary: { itemCount: number } }; + expect(data.summary.itemCount).toBe(3); + } + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(3); + expect(vault.encryptAndWriteManifest).toHaveBeenCalledTimes(1); + + // Manifest must have been re-keyed with the WASM-minted IDs (newid1, newid2, newid3). + expect(Object.keys(state.manifest!.items)).toHaveLength(3); + for (const key of Object.keys(state.manifest!.items)) { + expect(key).toMatch(/^newid\d/); + } + }); + + it('rejects when vault is locked', async () => { + const state: PopupState = { manifest: null, gitHost: null, wasm: fakeWasm() }; + const result = await handle( + { type: 'import_lastpass_commit', items: [sampleItem('X')] }, + state, + FAKE_SENDER, + ); + expect(result).toEqual({ ok: false, error: 'vault_locked' }); + }); + + it('rejects an empty items list', async () => { + const state: PopupState = { + manifest: EMPTY_MANIFEST, + gitHost: fakeHost as never, + wasm: fakeWasm(), + }; + const result = await handle( + { type: 'import_lastpass_commit', items: [] }, + state, + FAKE_SENDER, + ); + expect(result).toEqual({ ok: false, error: 'no items to import' }); + }); +}); From da6f08fa35d854511f924cdfe3a2c0adb951461a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:33:52 -0400 Subject: [PATCH 14/18] test(ext/router): sender matrix for LastPass import messages Co-Authored-By: Claude Opus 4.7 --- .../router/__tests__/router.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index bc07b33..af984d5 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -837,3 +837,47 @@ describe('export_backup / restore_backup sender check', () => { expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); + +// --- 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' }); + }); +}); From 66981588e752a736b8f8f2560b658b59cd7a0bf3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 30 Apr 2026 18:43:35 -0400 Subject: [PATCH 15/18] =?UTF-8?q?feat(ext/vault):=20Import=20panel=20?= =?UTF-8?q?=E2=80=94=20LastPass=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 →". Co-Authored-By: Claude Opus 4.7 --- .../src/popup/components/settings-vault.ts | 8 + .../src/vault/components/import-panel.ts | 182 ++++++++++++++++++ extension/src/vault/vault.ts | 8 +- 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 extension/src/vault/components/import-panel.ts diff --git a/extension/src/popup/components/settings-vault.ts b/extension/src/popup/components/settings-vault.ts index a5fc0e4..84e7935 100644 --- a/extension/src/popup/components/settings-vault.ts +++ b/extension/src/popup/components/settings-vault.ts @@ -165,6 +165,13 @@ export function renderVaultSettings(app: HTMLElement): void { +
+
import
+
+ +
+
+