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:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user