Merge feature/lastpass-importer: Plan 3B — LastPass CSV importer (v0.3.0)
17 tasks executed via subagent-driven development with two-stage review
per task and a final all-tasks code review (Approve-with-fixes; both
flagged items resolved as documentation tightenings in cf39601).
Adds:
- relicario import lastpass <csv> CLI command
- Vault-tab Import panel + popup deep-link
- WASM bridge parse_lastpass_csv_json
- 44 new tests (22 parser + 6 CLI + 5 SW + 4 router + 5 panel + 2 WASM)
Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md
Plan: docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -40,6 +40,20 @@
|
|||||||
passphrase + new-remote config and writes the vault into a fresh
|
passphrase + new-remote config and writes the vault into a fresh
|
||||||
empty repo (refuses to clobber existing). Git history is never
|
empty repo (refuses to clobber existing). Git history is never
|
||||||
bundled from the extension — CLI is the source of full backups.
|
bundled from the extension — CLI is the source of full backups.
|
||||||
|
- **LastPass CSV import.** New `relicario import lastpass <csv>`
|
||||||
|
command + vault-tab Import panel (`vault.html#import`).
|
||||||
|
Logins map to `Login` items; rows with `url == "http://sn"`
|
||||||
|
map to `SecureNote` (extra column → body verbatim, structured
|
||||||
|
data preserved as-is for manual re-categorization). TOTP
|
||||||
|
secrets in the `totp` column are base32-decoded into
|
||||||
|
`LoginCore.totp`; bad base32 surfaces a warning and the login
|
||||||
|
is imported without TOTP. Failed rows (missing `name`, missing
|
||||||
|
password on a login) are skipped with a per-row warning.
|
||||||
|
Each row gets a freshly-minted ID — re-running the import
|
||||||
|
creates duplicates rather than corrupting state.
|
||||||
|
- **Popup deep link to the Import panel.** `settings-vault`
|
||||||
|
gains an "import" section with a `LastPass CSV →` button
|
||||||
|
next to the existing `Backup & restore →` button.
|
||||||
- **`relicario status` shows last export age.** New `Last export:
|
- **`relicario status` shows last export age.** New `Last export:
|
||||||
<human-readable>` line reading `.relicario/last_backup` (a marker
|
<human-readable>` line reading `.relicario/last_backup` (a marker
|
||||||
file `cmd_backup_export` writes on success). Reads "never" for
|
file `cmd_backup_export` writes on success). Reads "never" for
|
||||||
|
|||||||
28
Cargo.lock
generated
28
Cargo.lock
generated
@@ -437,6 +437,27 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "curve25519-dalek"
|
name = "curve25519-dalek"
|
||||||
version = "4.1.3"
|
version = "4.1.3"
|
||||||
@@ -1609,6 +1630,7 @@ dependencies = [
|
|||||||
"bip39",
|
"bip39",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"csv",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -1696,6 +1718,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ enum Commands {
|
|||||||
action: BackupAction,
|
action: BackupAction,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Import items from another password manager into the unlocked vault.
|
||||||
|
Import {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ImportAction,
|
||||||
|
},
|
||||||
|
|
||||||
/// Attach a file to an item.
|
/// Attach a file to an item.
|
||||||
Attach { query: String, file: PathBuf },
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -323,6 +341,7 @@ fn main() -> Result<()> {
|
|||||||
Commands::Purge { query } => cmd_purge(query),
|
Commands::Purge { query } => cmd_purge(query),
|
||||||
Commands::Trash { action } => cmd_trash(action),
|
Commands::Trash { action } => cmd_trash(action),
|
||||||
Commands::Backup { action } => cmd_backup(action),
|
Commands::Backup { action } => cmd_backup(action),
|
||||||
|
Commands::Import { action } => cmd_import(action),
|
||||||
Commands::Attach { query, file } => cmd_attach(query, file),
|
Commands::Attach { query, file } => cmd_attach(query, file),
|
||||||
Commands::Attachments { query } => cmd_attachments(query),
|
Commands::Attachments { query } => cmd_attachments(query),
|
||||||
Commands::Extract { query, aid, out } => cmd_extract(query, aid, out),
|
Commands::Extract { query, aid, out } => cmd_extract(query, aid, out),
|
||||||
@@ -1539,6 +1558,87 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
Ok(())
|
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<String> = Vec::with_capacity(items.len() + 1);
|
||||||
|
|
||||||
|
for (idx, item) in items.iter().enumerate() {
|
||||||
|
vault.save_item(item)?;
|
||||||
|
manifest.upsert(item);
|
||||||
|
written_paths.push(format!("items/{}.enc", item.id.as_str()));
|
||||||
|
|
||||||
|
let n = idx + 1;
|
||||||
|
if n % 50 == 0 || n == total {
|
||||||
|
eprintln!("[{n}/{total}] importing...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
written_paths.push("manifest.enc".into());
|
||||||
|
|
||||||
|
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||||
|
let csv_filename = csv_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("lastpass.csv");
|
||||||
|
commit_paths(
|
||||||
|
&vault,
|
||||||
|
&format!("import: {} items from LastPass ({})", total, csv_filename),
|
||||||
|
&path_refs,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for w in &warnings {
|
||||||
|
print_warning(w);
|
||||||
|
}
|
||||||
|
// 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<()> {
|
fn cmd_trash_empty() -> Result<()> {
|
||||||
use relicario_core::time::now_unix;
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
|||||||
17
crates/relicario-cli/tests/fixtures/lastpass-sample.csv
vendored
Normal file
17
crates/relicario-cli/tests/fixtures/lastpass-sample.csv
vendored
Normal file
@@ -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,,
|
||||||
|
127
crates/relicario-cli/tests/import_lastpass.rs
Normal file
127
crates/relicario-cli/tests/import_lastpass.rs
Normal file
@@ -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}");
|
||||||
|
}
|
||||||
@@ -29,5 +29,6 @@ getrandom = "0.2"
|
|||||||
zstd = { version = "0.13", default-features = false }
|
zstd = { version = "0.13", default-features = false }
|
||||||
tar = { version = "0.4", default-features = false }
|
tar = { version = "0.4", default-features = false }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
csv = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ pub enum RelicarioError {
|
|||||||
#[error("backup envelope schema v{found}; this relicario reads v{expected}")]
|
#[error("backup envelope schema v{found}; this relicario reads v{expected}")]
|
||||||
BackupSchemaMismatch { found: u32, expected: u32 },
|
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.
|
/// An item was looked up by ID but does not exist in the manifest.
|
||||||
#[error("item not found: {0}")]
|
#[error("item not found: {0}")]
|
||||||
ItemNotFound(String),
|
ItemNotFound(String),
|
||||||
@@ -156,4 +167,15 @@ mod tests {
|
|||||||
let s = format!("{}", schema);
|
let s = format!("{}", schema);
|
||||||
assert!(s.contains("v2") && s.contains("v1"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
220
crates/relicario-core/src/import_lastpass.rs
Normal file
220
crates/relicario-core/src/import_lastpass.rs
Normal file
@@ -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<String>,
|
||||||
|
/// Human-readable explanation, suitable for stderr / inline UI.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a LastPass CSV export.
|
||||||
|
///
|
||||||
|
/// Returns the parsed items (with fresh IDs and timestamps) and any
|
||||||
|
/// per-row warnings. The function only fails if the header is missing
|
||||||
|
/// or doesn't match `EXPECTED_HEADER`.
|
||||||
|
pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec<Item>, Vec<ImportWarning>)> {
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(true)
|
||||||
|
.flexible(false)
|
||||||
|
.from_reader(csv_bytes);
|
||||||
|
|
||||||
|
// Validate header.
|
||||||
|
let headers = reader
|
||||||
|
.headers()
|
||||||
|
.map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))?
|
||||||
|
.clone();
|
||||||
|
if headers.len() != EXPECTED_HEADER.len()
|
||||||
|
|| headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want)
|
||||||
|
{
|
||||||
|
return Err(RelicarioError::ImportCsvHeader(format!(
|
||||||
|
"expected `{}`, got `{}`",
|
||||||
|
EXPECTED_HEADER.join(","),
|
||||||
|
headers.iter().collect::<Vec<_>>().join(",")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
for (idx, record) in reader.records().enumerate() {
|
||||||
|
let row_num = idx + 1;
|
||||||
|
let record = match record {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warnings.push(ImportWarning {
|
||||||
|
row: row_num,
|
||||||
|
title: None,
|
||||||
|
message: format!("CSV parse error — skipped: {e}"),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Item>, Option<ImportWarning>) {
|
||||||
|
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<ImportWarning> = 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<Vec<u8>> {
|
||||||
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
|
||||||
|
if upper.is_empty() { return None; }
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for ch in upper.bytes() {
|
||||||
|
let idx = ALPHA.iter().position(|&a| a == ch)?;
|
||||||
|
buffer = (buffer << 5) | (idx as u32);
|
||||||
|
bits += 5;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push(((buffer >> bits) & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
@@ -80,3 +80,6 @@ pub mod imgsecret;
|
|||||||
|
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
||||||
|
|
||||||
|
pub mod import_lastpass;
|
||||||
|
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
|||||||
276
crates/relicario-core/tests/import_lastpass.rs
Normal file
276
crates/relicario-core/tests/import_lastpass.rs
Normal file
@@ -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}");
|
||||||
|
}
|
||||||
@@ -432,6 +432,28 @@ pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result<String, JsEr
|
|||||||
Ok(json.to_string())
|
Ok(json.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LastPass CSV importer bridge ────────────────────────────────────────────
|
||||||
|
|
||||||
|
use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv;
|
||||||
|
|
||||||
|
/// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`.
|
||||||
|
///
|
||||||
|
/// Items are returned as full `Item` JSON objects with freshly-minted IDs
|
||||||
|
/// and timestamps already populated. The SW caller is responsible for
|
||||||
|
/// encrypting + writing them; this bridge stays pure so the preview UI
|
||||||
|
/// can render counts without committing anything.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
|
||||||
|
let (items, warnings) = core_parse_lastpass_csv(csv_bytes)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"items": items,
|
||||||
|
"warnings": warnings,
|
||||||
|
});
|
||||||
|
Ok(json.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod session_tests {
|
mod session_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -474,4 +496,31 @@ mod session_tests {
|
|||||||
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||||
assert_ne!(bytes, bytes2, "nonces must differ");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">import</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<button class="btn" id="open-import">LastPass CSV →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-footer">
|
<div class="settings-footer">
|
||||||
<button class="btn" id="discard-btn">discard</button>
|
<button class="btn" id="discard-btn">discard</button>
|
||||||
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
||||||
@@ -195,6 +202,7 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|||||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
|
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup'));
|
document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup'));
|
||||||
|
document.getElementById('open-import')?.addEventListener('click', () => openVaultTab('import'));
|
||||||
|
|
||||||
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
||||||
if (!pendingSettings) return;
|
if (!pendingSettings) return;
|
||||||
|
|||||||
178
extension/src/service-worker/__tests__/import.test.ts
Normal file
178
extension/src/service-worker/__tests__/import.test.ts
Normal file
@@ -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<typeof import('../vault')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
encryptAndWriteItem: vi.fn().mockResolvedValue(undefined),
|
||||||
|
encryptAndWriteManifest: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { handle, type PopupState } from '../router/popup-only';
|
||||||
|
import * as vault from '../vault';
|
||||||
|
import type { Manifest, Item } from '../../shared/types';
|
||||||
|
|
||||||
|
const FAKE_SENDER = {
|
||||||
|
url: 'chrome-extension://x/vault.html',
|
||||||
|
id: 'x',
|
||||||
|
frameId: 0,
|
||||||
|
} as unknown as chrome.runtime.MessageSender;
|
||||||
|
|
||||||
|
const EMPTY_MANIFEST: Manifest = { schema_version: 2, items: {} } as Manifest;
|
||||||
|
|
||||||
|
function fakeWasm() {
|
||||||
|
let counter = 0;
|
||||||
|
return {
|
||||||
|
parse_lastpass_csv_json: vi.fn().mockReturnValue(JSON.stringify({
|
||||||
|
items: [
|
||||||
|
sampleItem('GitHub'),
|
||||||
|
sampleItem('Gmail'),
|
||||||
|
],
|
||||||
|
warnings: [{ row: 3, title: 'No Pass', message: 'missing `password` — skipped' }],
|
||||||
|
})),
|
||||||
|
new_item_id: vi.fn(() => `newid${++counter}`.padEnd(16, '0')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleItem(title: string): Item {
|
||||||
|
return {
|
||||||
|
id: 'placeholder-id00',
|
||||||
|
title,
|
||||||
|
type: 'login',
|
||||||
|
tags: [],
|
||||||
|
favorite: false,
|
||||||
|
created: 1000,
|
||||||
|
modified: 1000,
|
||||||
|
core: { type: 'login', username: 'u', password: 'p' },
|
||||||
|
sections: [],
|
||||||
|
attachments: [],
|
||||||
|
field_history: {},
|
||||||
|
} as unknown as Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parse_lastpass_csv handler', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { chrome?: unknown }).chrome = {
|
||||||
|
storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns items + warnings from the WASM bridge', async () => {
|
||||||
|
const state: PopupState = {
|
||||||
|
manifest: EMPTY_MANIFEST,
|
||||||
|
gitHost: fakeHost as never,
|
||||||
|
wasm: fakeWasm(),
|
||||||
|
};
|
||||||
|
const result = await handle(
|
||||||
|
{ type: 'parse_lastpass_csv', bytes: new ArrayBuffer(8) },
|
||||||
|
state,
|
||||||
|
FAKE_SENDER,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
const data = result.data as { items: Item[]; warnings: unknown[] };
|
||||||
|
expect(data.items).toHaveLength(2);
|
||||||
|
expect(data.warnings).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces WASM errors as ok:false', async () => {
|
||||||
|
const state: PopupState = {
|
||||||
|
manifest: EMPTY_MANIFEST,
|
||||||
|
gitHost: fakeHost as never,
|
||||||
|
wasm: {
|
||||||
|
parse_lastpass_csv_json: vi.fn(() => { throw new Error('bad header'); }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await handle(
|
||||||
|
{ type: 'parse_lastpass_csv', bytes: new ArrayBuffer(0) },
|
||||||
|
state,
|
||||||
|
FAKE_SENDER,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: false, error: 'bad header' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('import_lastpass_commit handler', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(vault.encryptAndWriteItem as ReturnType<typeof vi.fn>).mockClear();
|
||||||
|
(vault.encryptAndWriteManifest as ReturnType<typeof vi.fn>).mockClear();
|
||||||
|
(globalThis as { chrome?: unknown }).chrome = {
|
||||||
|
storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn() } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypts + writes each item, manifest last', async () => {
|
||||||
|
const state: PopupState = {
|
||||||
|
manifest: { ...EMPTY_MANIFEST, items: {} },
|
||||||
|
gitHost: fakeHost as never,
|
||||||
|
wasm: fakeWasm(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await handle(
|
||||||
|
{
|
||||||
|
type: 'import_lastpass_commit',
|
||||||
|
items: [sampleItem('A'), sampleItem('B'), sampleItem('C')],
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
FAKE_SENDER,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
const data = result.data as { summary: { itemCount: number } };
|
||||||
|
expect(data.summary.itemCount).toBe(3);
|
||||||
|
}
|
||||||
|
expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(3);
|
||||||
|
expect(vault.encryptAndWriteManifest).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Manifest must have been re-keyed with the WASM-minted IDs (newid1, newid2, newid3).
|
||||||
|
expect(Object.keys(state.manifest!.items)).toHaveLength(3);
|
||||||
|
for (const key of Object.keys(state.manifest!.items)) {
|
||||||
|
expect(key).toMatch(/^newid\d/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when vault is locked', async () => {
|
||||||
|
const state: PopupState = { manifest: null, gitHost: null, wasm: fakeWasm() };
|
||||||
|
const result = await handle(
|
||||||
|
{ type: 'import_lastpass_commit', items: [sampleItem('X')] },
|
||||||
|
state,
|
||||||
|
FAKE_SENDER,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: false, error: 'vault_locked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty items list', async () => {
|
||||||
|
const state: PopupState = {
|
||||||
|
manifest: EMPTY_MANIFEST,
|
||||||
|
gitHost: fakeHost as never,
|
||||||
|
wasm: fakeWasm(),
|
||||||
|
};
|
||||||
|
const result = await handle(
|
||||||
|
{ type: 'import_lastpass_commit', items: [] },
|
||||||
|
state,
|
||||||
|
FAKE_SENDER,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: false, error: 'no items to import' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -837,3 +837,47 @@ describe('export_backup / restore_backup sender check', () => {
|
|||||||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -507,6 +507,52 @@ export async function handle(
|
|||||||
return { ok: false, error: (e as Error).message };
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export type PopupMessage =
|
|||||||
bytes: ArrayBuffer;
|
bytes: ArrayBuffer;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: 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 ---
|
// --- Messages a content script may send ---
|
||||||
|
|
||||||
@@ -161,6 +163,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
|||||||
'get_field_history',
|
'get_field_history',
|
||||||
'get_session_config', 'update_session_config',
|
'get_session_config', 'update_session_config',
|
||||||
'export_backup', 'restore_backup',
|
'export_backup', 'restore_backup',
|
||||||
|
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||||
@@ -173,6 +176,19 @@ export interface RestoreBackupResponse extends Extract<Response, { ok: true }> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParseLastPassCsvResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: {
|
||||||
|
items: Item[];
|
||||||
|
warnings: Array<{ row: number; title?: string; message: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportLastPassCommitResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: {
|
||||||
|
summary: { itemCount: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||||
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
||||||
'capture_save_login',
|
'capture_save_login',
|
||||||
|
|||||||
152
extension/src/vault/components/__tests__/import-panel.test.ts
Normal file
152
extension/src/vault/components/__tests__/import-panel.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', () => ({
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
registerHost: vi.fn(),
|
||||||
|
getState: vi.fn(),
|
||||||
|
setState: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
import { renderImportPanel, teardown } from '../import-panel';
|
||||||
|
|
||||||
|
const mockSendMessage = sendMessage as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function fakeItem(type: 'login' | 'secure_note', title: string) {
|
||||||
|
return {
|
||||||
|
id: 'fake0000',
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
tags: [],
|
||||||
|
favorite: false,
|
||||||
|
created: 0,
|
||||||
|
modified: 0,
|
||||||
|
core: type === 'login'
|
||||||
|
? { type: 'login', username: 'u', password: 'p' }
|
||||||
|
: { type: 'secure_note', body: 'note' },
|
||||||
|
sections: [],
|
||||||
|
attachments: [],
|
||||||
|
field_history: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFile(app: HTMLElement, body: string): Promise<void> {
|
||||||
|
const file = new File([body], 'export.csv', { type: 'text/csv' });
|
||||||
|
const input = app.querySelector('#lp-file') as HTMLInputElement;
|
||||||
|
Object.defineProperty(input, 'files', { value: [file] });
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
|
// Allow microtasks to drain.
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Import panel', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSendMessage.mockReset();
|
||||||
|
teardown();
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parsing fires parse_lastpass_csv with the file bytes', async () => {
|
||||||
|
renderImportPanel(app);
|
||||||
|
mockSendMessage.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { items: [fakeItem('login', 'A')], warnings: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await pickFile(app, 'url,username,password,totp,extra,name,grouping,fav\nhttps://x,u,p,,,A,,');
|
||||||
|
|
||||||
|
expect(mockSendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect((mockSendMessage.mock.calls[0][0] as { type: string }).type).toBe('parse_lastpass_csv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preview text shows parsed counts', async () => {
|
||||||
|
renderImportPanel(app);
|
||||||
|
mockSendMessage.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
items: [fakeItem('login', 'A'), fakeItem('login', 'B'), fakeItem('secure_note', 'N')],
|
||||||
|
warnings: [{ row: 5, title: 'X', message: 'missing `password` — skipped' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await pickFile(app, 'header,row,doesnt,matter,for,this,test,case');
|
||||||
|
|
||||||
|
const txt = (app.querySelector('#lp-preview-text') as HTMLElement).textContent ?? '';
|
||||||
|
expect(txt).toContain('2 logins');
|
||||||
|
expect(txt).toContain('1 notes');
|
||||||
|
expect(txt).toContain('1 skipped');
|
||||||
|
expect(txt).toContain('proceed?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm fires import_lastpass_commit with the parsed items', async () => {
|
||||||
|
renderImportPanel(app);
|
||||||
|
mockSendMessage.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { items: [fakeItem('login', 'A'), fakeItem('login', 'B')], warnings: [] },
|
||||||
|
});
|
||||||
|
mockSendMessage.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { summary: { itemCount: 2 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await pickFile(app, 'header');
|
||||||
|
(app.querySelector('#lp-confirm-btn') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
expect(mockSendMessage).toHaveBeenCalledTimes(2);
|
||||||
|
const second = mockSendMessage.mock.calls[1][0] as {
|
||||||
|
type: string;
|
||||||
|
items: unknown[];
|
||||||
|
};
|
||||||
|
expect(second.type).toBe('import_lastpass_commit');
|
||||||
|
expect(second.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning list after a successful import', async () => {
|
||||||
|
renderImportPanel(app);
|
||||||
|
mockSendMessage.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
items: [fakeItem('login', 'A')],
|
||||||
|
warnings: [
|
||||||
|
{ row: 4, title: 'AWS', message: 'invalid base32 TOTP secret — login imported without TOTP' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockSendMessage.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { summary: { itemCount: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await pickFile(app, 'header');
|
||||||
|
(app.querySelector('#lp-confirm-btn') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const list = app.querySelector('#lp-warning-list')?.textContent ?? '';
|
||||||
|
expect(list).toContain('row 4');
|
||||||
|
expect(list).toContain('AWS');
|
||||||
|
expect(list).toContain('TOTP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel clears the preview', async () => {
|
||||||
|
renderImportPanel(app);
|
||||||
|
mockSendMessage.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { items: [fakeItem('login', 'A')], warnings: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await pickFile(app, 'header');
|
||||||
|
expect(app.querySelector('#lp-preview')!.classList.contains('hidden')).toBe(false);
|
||||||
|
|
||||||
|
(app.querySelector('#lp-cancel-btn') as HTMLButtonElement).click();
|
||||||
|
expect(app.querySelector('#lp-preview')!.classList.contains('hidden')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
182
extension/src/vault/components/import-panel.ts
Normal file
182
extension/src/vault/components/import-panel.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { sendMessage } from '../../shared/state';
|
||||||
|
import type { Item } from '../../shared/types';
|
||||||
|
|
||||||
|
type ViewMode = 'idle' | 'parsing' | 'preview' | 'committing' | 'done';
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
items: Item[];
|
||||||
|
warnings: Array<{ row: number; title?: string; message: string }>;
|
||||||
|
loginCount: number;
|
||||||
|
noteCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: ViewMode = 'idle';
|
||||||
|
let preview: PreviewState | null = null;
|
||||||
|
|
||||||
|
export function renderImportPanel(app: HTMLElement): void {
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Import</h1>
|
||||||
|
|
||||||
|
<section class="card" id="import-card">
|
||||||
|
<h2>LastPass CSV</h2>
|
||||||
|
<p>Pick the CSV exported from LastPass
|
||||||
|
(<code>More options → Advanced → Export</code>).
|
||||||
|
Items are added to the unlocked vault with fresh IDs.
|
||||||
|
Title collisions are kept — no dedup. Failed rows are
|
||||||
|
skipped and reported below.</p>
|
||||||
|
|
||||||
|
<input type="file" id="lp-file" accept=".csv,text/csv">
|
||||||
|
|
||||||
|
<div id="lp-preview" class="hidden">
|
||||||
|
<p id="lp-preview-text"></p>
|
||||||
|
<button id="lp-confirm-btn">Import…</button>
|
||||||
|
<button id="lp-cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lp-progress" class="hidden">
|
||||||
|
<p id="lp-progress-text"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lp-warnings" class="hidden">
|
||||||
|
<h3>Warnings</h3>
|
||||||
|
<ul id="lp-warning-list"></ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
wireImport(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireImport(scope: HTMLElement): void {
|
||||||
|
const fileEl = scope.querySelector('#lp-file') as HTMLInputElement;
|
||||||
|
const previewEl = scope.querySelector('#lp-preview') as HTMLElement;
|
||||||
|
const previewTx = scope.querySelector('#lp-preview-text') as HTMLElement;
|
||||||
|
const confirmBt = scope.querySelector('#lp-confirm-btn') as HTMLButtonElement;
|
||||||
|
const cancelBt = scope.querySelector('#lp-cancel-btn') as HTMLButtonElement;
|
||||||
|
const progressE = scope.querySelector('#lp-progress') as HTMLElement;
|
||||||
|
const progressT = scope.querySelector('#lp-progress-text') as HTMLElement;
|
||||||
|
const warningsE = scope.querySelector('#lp-warnings') as HTMLElement;
|
||||||
|
const warningsL = scope.querySelector('#lp-warning-list') as HTMLElement;
|
||||||
|
|
||||||
|
fileEl.addEventListener('change', async () => {
|
||||||
|
const file = fileEl.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (mode !== 'idle' && mode !== 'done') return;
|
||||||
|
resetPanels(previewEl, progressE, warningsE);
|
||||||
|
|
||||||
|
mode = 'parsing';
|
||||||
|
progressE.classList.remove('hidden');
|
||||||
|
progressT.textContent = 'Parsing…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const resp = await sendMessage({ type: 'parse_lastpass_csv', bytes });
|
||||||
|
progressE.classList.add('hidden');
|
||||||
|
if (!resp.ok) {
|
||||||
|
previewTx.textContent = `Failed to parse: ${resp.error}`;
|
||||||
|
previewEl.classList.remove('hidden');
|
||||||
|
confirmBt.classList.add('hidden');
|
||||||
|
cancelBt.classList.remove('hidden');
|
||||||
|
mode = 'idle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (resp as { ok: true; data: PreviewState }).data;
|
||||||
|
preview = aggregate(data);
|
||||||
|
previewTx.textContent =
|
||||||
|
`${preview.loginCount} logins, ${preview.noteCount} notes, ` +
|
||||||
|
`${preview.warnings.length} skipped/warn — proceed?`;
|
||||||
|
previewEl.classList.remove('hidden');
|
||||||
|
confirmBt.classList.remove('hidden');
|
||||||
|
cancelBt.classList.remove('hidden');
|
||||||
|
confirmBt.disabled = preview.items.length === 0;
|
||||||
|
mode = 'preview';
|
||||||
|
} catch (e) {
|
||||||
|
progressE.classList.add('hidden');
|
||||||
|
previewTx.textContent = `Failed: ${(e as Error).message}`;
|
||||||
|
previewEl.classList.remove('hidden');
|
||||||
|
mode = 'idle';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBt.addEventListener('click', () => {
|
||||||
|
preview = null;
|
||||||
|
fileEl.value = '';
|
||||||
|
resetPanels(previewEl, progressE, warningsE);
|
||||||
|
mode = 'idle';
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBt.addEventListener('click', async () => {
|
||||||
|
if (mode !== 'preview' || !preview) return;
|
||||||
|
mode = 'committing';
|
||||||
|
confirmBt.disabled = true;
|
||||||
|
cancelBt.disabled = true;
|
||||||
|
progressE.classList.remove('hidden');
|
||||||
|
progressT.textContent = `Importing ${preview.items.length} items…`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await sendMessage({
|
||||||
|
type: 'import_lastpass_commit',
|
||||||
|
items: preview.items,
|
||||||
|
});
|
||||||
|
progressE.classList.add('hidden');
|
||||||
|
if (!resp.ok) {
|
||||||
|
previewTx.textContent = `Import failed: ${resp.error}`;
|
||||||
|
mode = 'idle';
|
||||||
|
confirmBt.disabled = false;
|
||||||
|
cancelBt.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (resp as { ok: true; data: { summary: { itemCount: number } } }).data;
|
||||||
|
previewTx.textContent =
|
||||||
|
`Imported ${data.summary.itemCount} items. Reload the sidebar to see them.`;
|
||||||
|
confirmBt.classList.add('hidden');
|
||||||
|
cancelBt.textContent = 'Done';
|
||||||
|
|
||||||
|
if (preview.warnings.length > 0) {
|
||||||
|
warningsL.innerHTML = preview.warnings
|
||||||
|
.map((w) => {
|
||||||
|
const head = w.title ? `row ${w.row} (${escapeText(w.title)})` : `row ${w.row}`;
|
||||||
|
return `<li>${head}: ${escapeText(w.message)}</li>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
warningsE.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
mode = 'done';
|
||||||
|
} catch (e) {
|
||||||
|
progressE.classList.add('hidden');
|
||||||
|
previewTx.textContent = `Failed: ${(e as Error).message}`;
|
||||||
|
confirmBt.disabled = false;
|
||||||
|
cancelBt.disabled = false;
|
||||||
|
mode = 'idle';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregate(data: PreviewState): PreviewState {
|
||||||
|
let loginCount = 0;
|
||||||
|
let noteCount = 0;
|
||||||
|
for (const item of data.items) {
|
||||||
|
if (item.type === 'login') loginCount += 1;
|
||||||
|
else if (item.type === 'secure_note') noteCount += 1;
|
||||||
|
}
|
||||||
|
return { ...data, loginCount, noteCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPanels(preview: HTMLElement, progress: HTMLElement, warnings: HTMLElement): void {
|
||||||
|
preview.classList.add('hidden');
|
||||||
|
progress.classList.add('hidden');
|
||||||
|
warnings.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
preview = null;
|
||||||
|
mode = 'idle';
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { renderSettings } from '../popup/components/settings';
|
|||||||
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||||
|
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -67,7 +68,7 @@ function typeLabel(t: ItemType): string {
|
|||||||
// Hash routing
|
// Hash routing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup';
|
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup' | 'import';
|
||||||
|
|
||||||
interface HashRoute {
|
interface HashRoute {
|
||||||
view: VaultView;
|
view: VaultView;
|
||||||
@@ -94,6 +95,7 @@ function parseHash(): HashRoute {
|
|||||||
case 'settings-vault':
|
case 'settings-vault':
|
||||||
case 'field-history':
|
case 'field-history':
|
||||||
case 'backup':
|
case 'backup':
|
||||||
|
case 'import':
|
||||||
return { view };
|
return { view };
|
||||||
default:
|
default:
|
||||||
return { view: 'list' };
|
return { view: 'list' };
|
||||||
@@ -421,6 +423,7 @@ function teardownPaneComponents(): void {
|
|||||||
teardownDevices();
|
teardownDevices();
|
||||||
teardownFieldHistory();
|
teardownFieldHistory();
|
||||||
teardownBackup();
|
teardownBackup();
|
||||||
|
teardownImport();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPane(): void {
|
function renderPane(): void {
|
||||||
@@ -470,6 +473,9 @@ function renderPane(): void {
|
|||||||
case 'backup':
|
case 'backup':
|
||||||
renderBackupPanel(pane);
|
renderBackupPanel(pane);
|
||||||
break;
|
break;
|
||||||
|
case 'import':
|
||||||
|
renderImportPanel(pane);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
pane.className = 'vault-pane vault-pane--empty';
|
pane.className = 'vault-pane vault-pane--empty';
|
||||||
pane.innerHTML = 'select an item';
|
pane.innerHTML = 'select an item';
|
||||||
|
|||||||
Reference in New Issue
Block a user