feat(core): import_lastpass — URL/header robustness

Bad URLs in login rows downgrade to url: None with a warning
rather than skipping the row. Header mismatches (extra columns,
wrong order) surface ImportCsvHeader. Quoted commas, multi-line
extra, unicode all parse cleanly via the csv crate's defaults.
This commit is contained in:
adlee-was-taken
2026-04-29 23:09:23 -04:00
parent 0841bddcb5
commit 6f2e868892
2 changed files with 95 additions and 7 deletions

View File

@@ -132,9 +132,27 @@ fn map_row(
})); }));
} }
let parsed_url = if url.is_empty() { None } else { Url::parse(url).ok() };
let mut warning: Option<ImportWarning> = None; 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() { let totp = if totp_raw.is_empty() {
None None
} else { } else {
@@ -147,11 +165,14 @@ fn map_row(
kind: crate::item_types::TotpKind::Totp, kind: crate::item_types::TotpKind::Totp,
}), }),
_ => { _ => {
if warning.is_none() {
warning = Some(ImportWarning { warning = Some(ImportWarning {
row, row,
title: Some(name.to_string()), title: Some(name.to_string()),
message: "invalid base32 TOTP secret — login imported without TOTP".into(), message: "invalid base32 TOTP secret — login imported without TOTP"
.into(),
}); });
}
None None
} }
} }

View File

@@ -207,3 +207,70 @@ fn secure_note_preserves_structured_extra_verbatim() {
_ => unreachable!(), _ => 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}");
}