diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a1137..64660c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,20 @@ passphrase + new-remote config and writes the vault into a fresh empty repo (refuses to clobber existing). Git history is never bundled from the extension — CLI is the source of full backups. +- **LastPass CSV import.** New `relicario import lastpass ` + 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. - **`relicario status` shows last export age.** New `Last export: ` line reading `.relicario/last_backup` (a marker file `cmd_backup_export` writes on success). Reads "never" for 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-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 54009ed..6153420 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,87 @@ 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_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); + } + // Counts only true skips, not partial imports. Coupled by convention to + // the parser's warning message strings: skip messages end in "— skipped", + // partial-import messages say "imported without TOTP" / "imported without URL". + // If a future warning uses the word "skipped" in any other sense, this filter + // will need to switch to an enum tag (see ImportWarning::message). + 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<()> { use relicario_core::time::now_unix; 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}"); +} 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")); + } } diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs new file mode 100644 index 0000000..23f2be4 --- /dev/null +++ b/crates/relicario-core/src/import_lastpass.rs @@ -0,0 +1,220 @@ +//! 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, SecureNoteCore}; + +/// 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; + } + }; + + 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. 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(); + // password and extra are deliberately NOT trimmed: leading/trailing + // whitespace is significant inside passwords and free-form notes. + 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(), + })); + } + + // 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 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 { + 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, + }), + _ => { + if warning.is_none() { + 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) +} + +/// 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/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..7cda219 --- /dev/null +++ b/crates/relicario-core/tests/import_lastpass.rs @@ -0,0 +1,276 @@ +//! 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"; + +#[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() +} + +#[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")); +} + +#[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!(), + } +} + +#[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!(), + } +} + +#[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}"); +} 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()); + } } 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
+
+ +
+
+