From 0841bddcb57806e273f86fd58264f021365a6767 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 29 Apr 2026 23:06:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(core):=20import=5Flastpass=20=E2=80=94=20S?= =?UTF-8?q?ecureNote=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!(), + } +}