kv-import: 1Password .1pux and CSV import, encrypted export (M5)

New crate kv-import (pure parsing, no store) and kv-ffi/src/import.rs:
- .1pux: accounts -> vaults -> items; Login/Password/Card/Note/Identity mapped, other categories
  as secure notes; unmapped fields appended to notes; attachments and password history reported.
- CSV: 1Password 8 export and generic headers (aliases, BOM, quotes, embedded newlines,
  extra columns into notes).
- Duplicates on (title, username, URL host): skip or add " (imported)".
- Encrypted export KVEX v1: Argon2id (>= account KDF minimum) + XChaCha20-Poly1305,
  authenticated header, padded JSON; restorable through import.
- Caps: 100 MB input, 64 MB decompressed export.data, 10k zip entries, 100k items.
- FFI: VaultCore.import_preview / import_commit / export_encrypted (unlocked only); items are
  sealed, seq-stamped and queued for sync like save_item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 00:36:21 +09:00
co-authored by Claude Opus 5.5
parent 5eb2b622dd
commit 26664e141b
15 changed files with 2052 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
Title,Url,Username,Password,OTPAuth,Favorite,Archived,Tags,Notes
Example Git,https://git.example.com/login,[email protected],correct-horse-battery-staple,otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example,true,false,"dev,work","Main code hosting account.
Second line, with a comma and ""quotes""."
Old Forum,https://forum.example.org,alex," leading-space-pw",,false,true,,
Router,,,router-synthetic-pw,,false,false,,Router admin
1 Title Url Username Password OTPAuth Favorite Archived Tags Notes
2 Example Git https://git.example.com/login [email protected] correct-horse-battery-staple otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example true false dev,work Main code hosting account. Second line, with a comma and "quotes".
3 Old Forum https://forum.example.org alex leading-space-pw false true
4 Router router-synthetic-pw false false Router admin
+253
View File
@@ -0,0 +1,253 @@
{
"accounts": [
{
"attrs": {
"accountName": "Synthetic Test",
"name": "Alex Example",
"avatar": "",
"email": "[email protected]",
"uuid": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
"domain": "https://example.1password.com/"
},
"vaults": [
{
"attrs": { "uuid": "vaultpersonal00000000000000", "desc": "", "avatar": "", "name": "Personal", "type": "P" },
"items": [
{
"uuid": "item0login0totp000000000000",
"favIndex": 1,
"createdAt": 1614298956,
"updatedAt": 1700000000,
"state": "active",
"categoryUuid": "001",
"details": {
"loginFields": [
{ "value": "[email protected]", "id": "", "name": "email", "fieldType": "E", "designation": "username" },
{ "value": "correct-horse-battery-staple", "id": "", "name": "password", "fieldType": "P", "designation": "password" },
{ "value": "", "id": "", "name": "remember", "fieldType": "C", "designation": "" }
],
"notesPlain": "Main code hosting account.",
"sections": [
{
"title": "",
"name": "add more",
"fields": [
{
"title": "one-time password",
"id": "TOTP_0001",
"value": { "totp": "otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example" },
"indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false,
"inputTraits": { "keyboard": "default", "correction": "default", "capitalization": "default" }
}
]
},
{
"title": "Security questions",
"name": "linked items",
"fields": [
{ "title": "First pet", "id": "q1", "value": { "concealed": "Synthetic Fluffy" }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": true, "inputTraits": {} },
{ "title": "Recovery codes", "id": "rc", "value": { "string": "1111-2222\n3333-4444" }, "indexAtSource": 1, "guarded": false, "multiline": true, "dontGenerate": false, "inputTraits": {} }
]
}
],
"passwordHistory": [ { "value": "old-synthetic-password", "time": 1600000000 } ]
},
"overview": {
"subtitle": "[email protected]",
"urls": [
{ "label": "", "url": "https://git.example.com/login" },
{ "label": "website", "url": "https://www.example.com" }
],
"title": "Example Git",
"url": "https://git.example.com/login",
"ps": 100, "pbe": 86.2, "pgrng": true,
"tags": ["dev", "work"]
}
},
{
"uuid": "item1login0bare000000000000",
"favIndex": 0,
"createdAt": 1620000000,
"updatedAt": 1620000000,
"state": "archived",
"categoryUuid": "001",
"details": {
"loginFields": [
{ "value": "alex", "id": "", "name": "user", "fieldType": "T", "designation": "username" },
{ "value": " leading-space-pw", "id": "", "name": "pass", "fieldType": "P", "designation": "password" }
],
"notesPlain": "",
"sections": [
{ "title": "", "name": "", "fields": [ { "title": "2FA", "id": "otp2", "value": { "totp": "GEZDGNBVGY3TQOJQ" }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} } ] }
],
"passwordHistory": []
},
"overview": { "subtitle": "alex", "urls": [ { "label": "", "url": "https://forum.example.org" } ], "title": "Old Forum", "url": "https://forum.example.org", "ps": 40, "pbe": 30.0, "pgrng": false }
},
{
"uuid": "item2card000000000000000000",
"favIndex": 0,
"createdAt": 1630000000,
"updatedAt": 1630000000,
"state": "active",
"categoryUuid": "002",
"details": {
"loginFields": [],
"notesPlain": "Travel card.",
"sections": [
{
"title": "",
"name": "",
"fields": [
{ "title": "cardholder name", "id": "cardholder", "value": { "string": "ALEX EXAMPLE" }, "indexAtSource": 0, "guarded": true, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "type", "id": "type", "value": { "creditCardType": "visa" }, "indexAtSource": 1, "guarded": true, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "number", "id": "ccnum", "value": { "creditCardNumber": "4111111111111111" }, "indexAtSource": 2, "guarded": true, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "verification number", "id": "cvv", "value": { "concealed": "123" }, "indexAtSource": 3, "guarded": true, "multiline": false, "dontGenerate": true, "inputTraits": {} },
{ "title": "expiry date", "id": "expiry", "value": { "monthYear": 202712 }, "indexAtSource": 4, "guarded": true, "multiline": false, "dontGenerate": false, "inputTraits": {} }
]
},
{
"title": "Contact Information",
"name": "contactInfo",
"fields": [
{ "title": "issuing bank", "id": "bank", "value": { "string": "Example Bank" }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "phone (intl)", "id": "phoneIntl", "value": { "phone": "+1 555 0100" }, "indexAtSource": 1, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} }
]
}
],
"passwordHistory": []
},
"overview": { "subtitle": "4111 **** 1111", "title": "Example Visa", "url": "", "ps": 0, "pbe": 0.0, "pgrng": false }
},
{
"uuid": "item3note000000000000000000",
"favIndex": 0,
"createdAt": 1640000000,
"updatedAt": 1640000000,
"state": "active",
"categoryUuid": "003",
"details": { "loginFields": [], "notesPlain": "Wi-Fi: SyntheticNet\nKey: not-a-real-key", "sections": [], "passwordHistory": [] },
"overview": { "subtitle": "", "title": "Home Wi-Fi", "url": "", "ps": 0, "pbe": 0.0, "pgrng": false, "tags": ["home"] }
},
{
"uuid": "item4identity00000000000000",
"favIndex": 0,
"createdAt": 1650000000,
"updatedAt": 1650000000,
"state": "active",
"categoryUuid": "004",
"details": {
"loginFields": [],
"notesPlain": "",
"sections": [
{
"title": "Identification",
"name": "name",
"fields": [
{ "title": "first name", "id": "firstname", "value": { "string": "Alex" }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "last name", "id": "lastname", "value": { "string": "Example" }, "indexAtSource": 1, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "birth date", "id": "birthdate", "value": { "date": 951782400 }, "indexAtSource": 2, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} }
]
},
{
"title": "Address",
"name": "address",
"fields": [
{ "title": "address", "id": "address", "value": { "address": { "street": "1 Example Way", "city": "Sampletown", "country": "us", "zip": "00000", "state": "CA" } }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "phone", "id": "defphone", "value": { "phone": "+1 555 0101" }, "indexAtSource": 1, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} }
]
},
{
"title": "Internet Details",
"name": "internet",
"fields": [
{ "title": "email", "id": "email", "value": { "email": { "email_address": "[email protected]", "provider": null } }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} }
]
}
],
"passwordHistory": []
},
"overview": { "subtitle": "Alex Example", "title": "Alex Example", "url": "", "ps": 0, "pbe": 0.0, "pgrng": false }
},
{
"uuid": "item5attachment000000000000",
"favIndex": 0,
"createdAt": 1660000000,
"updatedAt": 1660000000,
"state": "active",
"categoryUuid": "001",
"details": {
"loginFields": [
{ "value": "alex", "id": "", "name": "username", "fieldType": "T", "designation": "username" },
{ "value": "insurance-synthetic-pw", "id": "", "name": "password", "fieldType": "P", "designation": "password" }
],
"notesPlain": "Policy scan attached.",
"sections": [
{
"title": "",
"name": "",
"fields": [
{ "title": "policy.pdf", "id": "file1", "value": { "file": { "fileName": "policy.pdf", "documentId": "doc0000000000000000000000a", "decryptedSize": 1234 } }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} }
]
}
],
"passwordHistory": []
},
"overview": { "subtitle": "alex", "urls": [ { "label": "", "url": "https://insurance.example.net" } ], "title": "Example Insurance", "url": "https://insurance.example.net", "ps": 60, "pbe": 50.0, "pgrng": false }
},
{
"uuid": "item6password000000000000000",
"favIndex": 0,
"createdAt": 1670000000,
"updatedAt": 1670000000,
"state": "active",
"categoryUuid": "005",
"details": { "loginFields": [], "notesPlain": "Router admin", "sections": [], "passwordHistory": [], "password": "router-synthetic-pw" },
"overview": { "subtitle": "", "title": "Router", "url": "", "ps": 50, "pbe": 40.0, "pgrng": true }
},
{
"uuid": "item7trashed000000000000000",
"favIndex": 0,
"createdAt": 1670000000,
"updatedAt": 1670000000,
"state": "deleted",
"categoryUuid": "001",
"details": { "loginFields": [], "notesPlain": "", "sections": [], "passwordHistory": [] },
"overview": { "subtitle": "", "title": "Trashed", "url": "", "ps": 0, "pbe": 0.0, "pgrng": false }
}
]
},
{
"attrs": { "uuid": "vaultshared0000000000000000", "desc": "", "avatar": "", "name": "Shared", "type": "E" },
"items": [
{
"uuid": "item8server0000000000000000",
"favIndex": 0,
"createdAt": 1680000000,
"updatedAt": 1680000000,
"state": "active",
"categoryUuid": "110",
"details": {
"loginFields": [],
"notesPlain": "",
"sections": [
{
"title": "",
"name": "",
"fields": [
{ "title": "URL", "id": "url", "value": { "string": "ssh://build.example.com" }, "indexAtSource": 0, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "username", "id": "username", "value": { "string": "deploy" }, "indexAtSource": 1, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} },
{ "title": "password", "id": "password", "value": { "concealed": "server-synthetic-pw" }, "indexAtSource": 2, "guarded": false, "multiline": false, "dontGenerate": false, "inputTraits": {} }
]
}
],
"passwordHistory": []
},
"overview": { "subtitle": "", "title": "Build Server", "url": "", "ps": 0, "pbe": 0.0, "pgrng": false }
}
]
}
]
}
]
}
+4
View File
@@ -0,0 +1,4 @@
NAME,Website,Login,PASSWORD,TOTP,Note,Security Question
Mail,mail.example.com,alex,mail-synthetic-pw,JBSWY3DPEHPK3PXP,"multi
line",Synthetic Fluffy
,https://bank.example.com/login,alex,bank-synthetic-pw,,,
1 NAME Website Login PASSWORD TOTP Note Security Question
2 Mail mail.example.com alex mail-synthetic-pw JBSWY3DPEHPK3PXP multi line Synthetic Fluffy
3 https://bank.example.com/login alex bank-synthetic-pw
+316
View File
@@ -0,0 +1,316 @@
//! Fixture-based tests. All fixture data is synthetic (example.com domains, test card numbers).
use std::io::{Cursor, Write};
use kv_import::{export, parse, parse_with_limits, Format, ImportError, Limits, Parsed};
use kv_model::{Item, ItemKind};
use zip::write::SimpleFileOptions;
const NOW: i64 = 1_760_000_000;
const EXPORT_DATA: &str = include_str!("fixtures/1pux-export.data.json");
/// Builds a .1pux the way 1Password lays it out.
fn onepux(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, data) in entries {
if name.ends_with('/') {
zip.add_directory(*name, opts).unwrap();
} else {
zip.start_file(*name, opts).unwrap();
zip.write_all(data).unwrap();
}
}
zip.finish().unwrap().into_inner()
}
fn fixture_1pux() -> Vec<u8> {
onepux(&[
("export.attributes", br#"{"version":3,"description":"1Password Unencrypted Export","createdAt":1700000000}"#),
("export.data", EXPORT_DATA.as_bytes()),
("files/", b""),
("files/doc0000000000000000000000a__policy.pdf", b"%PDF-1.4 synthetic"),
])
}
fn by_title<'a>(p: &'a Parsed, title: &str) -> &'a Item {
p.items.iter().find(|i| i.title == title).unwrap_or_else(|| panic!("no item {title}"))
}
#[test]
fn onepux_fixture() {
let p = parse(&fixture_1pux(), &Format::OnePux, NOW).unwrap();
// 9 items, one deleted.
assert_eq!(p.items.len(), 8);
assert_eq!(p.count(ItemKind::Login), 4);
assert_eq!(p.count(ItemKind::Card), 1);
assert_eq!(p.count(ItemKind::Identity), 1);
assert_eq!(p.count(ItemKind::SecureNote), 2);
let git = by_title(&p, "Example Git");
assert_eq!(git.kind, ItemKind::Login);
assert_eq!(git.username.as_deref(), Some("[email protected]"));
assert_eq!(git.password.as_deref(), Some("correct-horse-battery-staple"));
assert_eq!(git.urls, vec!["https://git.example.com/login", "https://www.example.com"]);
assert!(git.totp.as_deref().unwrap().starts_with("otpauth://totp/Example:"));
assert!(git.favorite);
assert_eq!(git.tags, vec!["dev", "work", "Personal"]);
assert_eq!(git.created_at, 1_614_298_956);
assert_eq!(git.modified_at, 1_700_000_000);
assert_eq!(git.seq, 1);
let notes = git.notes.as_deref().unwrap();
assert!(notes.starts_with("Main code hosting account.\n\n— Imported from 1Password —"));
assert!(notes.contains("[Security questions]\nFirst pet: Synthetic Fluffy\nRecovery codes: 1111-2222\n3333-4444"));
let forum = by_title(&p, "Old Forum");
assert_eq!(forum.password.as_deref(), Some(" leading-space-pw"), "passwords are kept verbatim");
assert_eq!(forum.totp.as_deref(), Some("GEZDGNBVGY3TQOJQ"));
assert!(forum.tags.contains(&"Archived".to_string()));
assert!(forum.notes.is_none());
let card = by_title(&p, "Example Visa");
assert_eq!(card.kind, ItemKind::Card);
let n = card.notes.as_deref().unwrap();
for line in [
"Travel card.",
"cardholder name: ALEX EXAMPLE",
"type: visa",
"number: 4111111111111111",
"verification number: 123",
"expiry date: 12/2027",
"[Contact Information]\nissuing bank: Example Bank",
] {
assert!(n.contains(line), "card notes missing {line:?}:\n{n}");
}
let note = by_title(&p, "Home Wi-Fi");
assert_eq!(note.kind, ItemKind::SecureNote);
assert_eq!(note.notes.as_deref(), Some("Wi-Fi: SyntheticNet\nKey: not-a-real-key"));
let id = by_title(&p, "Alex Example");
assert_eq!(id.kind, ItemKind::Identity);
let n = id.notes.as_deref().unwrap();
assert!(n.contains("first name: Alex"));
assert!(n.contains("birth date: 2000-02-29"));
assert!(n.contains("address: 1 Example Way, Sampletown, CA, 00000, us"));
assert!(n.contains("email: [email protected]"));
let router = by_title(&p, "Router");
assert_eq!(router.kind, ItemKind::Login);
assert_eq!(router.password.as_deref(), Some("router-synthetic-pw"));
let server = by_title(&p, "Build Server");
assert_eq!(server.kind, ItemKind::SecureNote);
assert!(server.notes.as_deref().unwrap().contains("password: server-synthetic-pw"));
assert_eq!(server.tags, vec!["Shared"]);
// The attachment is reported, not imported.
assert_eq!(p.attachments_skipped, 1);
assert!(p.warnings.iter().any(|w| w.contains("\"Example Insurance\": attachment \"policy.pdf\" was not imported")));
assert!(p.warnings.iter().any(|w| w.contains("1 previous password")));
assert!(p.warnings.iter().any(|w| w.contains("1 deleted item")));
assert!(p.warnings.iter().any(|w| w.contains("1 item(s) of other categories")));
// Warnings never carry secret values.
for w in &p.warnings {
for secret in ["correct-horse", "4111", "server-synthetic-pw", "old-synthetic-password"] {
assert!(!w.contains(secret), "warning leaks a secret: {w}");
}
}
}
#[test]
fn onepux_unreferenced_files_are_reported() {
let data = br#"{"accounts":[{"attrs":{},"vaults":[{"attrs":{"name":"Personal"},"items":[]}]}]}"#;
let zip = onepux(&[("export.data", data), ("files/x__a.bin", b"1"), ("files/y__b.bin", b"2")]);
let p = parse(&zip, &Format::OnePux, NOW).unwrap();
assert!(p.items.is_empty());
assert_eq!(p.attachments_skipped, 2);
}
#[test]
fn zip_bomb_decompressed_size() {
// 8 MiB of zeros compresses to a few KiB.
let zeros = vec![b' '; 8 * 1024 * 1024];
let zip = onepux(&[("export.data", &zeros)]);
assert!(zip.len() < 100 * 1024);
let limits = Limits { max_decompressed: 1024 * 1024, ..Limits::default() };
assert_eq!(
parse_with_limits(&zip, &Format::OnePux, NOW, &limits).unwrap_err(),
ImportError::TooLarge { limit: 1024 * 1024 }
);
}
#[test]
fn zip_bomb_entry_count() {
let names: Vec<String> = (0..50).map(|i| format!("files/{i}__f")).collect();
let mut entries: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &b""[..])).collect();
entries.push(("export.data", b"{\"accounts\":[]}"));
let limits = Limits { max_entries: 20, ..Limits::default() };
assert_eq!(
parse_with_limits(&onepux(&entries), &Format::OnePux, NOW, &limits).unwrap_err(),
ImportError::TooManyEntries { limit: 20 }
);
}
#[test]
fn input_size_cap() {
let limits = Limits { max_input: 10, ..Limits::default() };
assert_eq!(
parse_with_limits(&[0u8; 11], &Format::Csv, NOW, &limits).unwrap_err(),
ImportError::TooLarge { limit: 10 }
);
}
#[test]
fn item_count_cap() {
let csv = "title,password\na,1\nb,2\nc,3\n";
let limits = Limits { max_items: 2, ..Limits::default() };
assert!(matches!(parse_with_limits(csv.as_bytes(), &Format::Csv, NOW, &limits), Err(ImportError::Malformed(_))));
}
#[test]
fn malformed_inputs_error_without_panicking() {
let full = fixture_1pux();
let cases: Vec<(Vec<u8>, &str)> = vec![
(vec![], "empty"),
(b"not a zip at all".to_vec(), "garbage"),
(full[..full.len() / 2].to_vec(), "truncated zip"),
(onepux(&[("other.json", b"{}")]), "no export.data"),
(onepux(&[("export.data", b"{not json")]), "bad json"),
(onepux(&[("export.data", b"[1,2,3]")]), "no accounts"),
(onepux(&[("export.data", br#"{"accounts":[{"vaults":[{"items":[42]}]}]}"#)]), "item not object"),
(
onepux(&[(
"export.data",
br#"{"accounts":[{"vaults":[{"items":[{"categoryUuid":7,"overview":[],"details":{"sections":"x","loginFields":[1,{"value":5}]}}]}]}]}"#,
)]),
"wrong types",
),
];
for (bytes, what) in cases {
let r = std::panic::catch_unwind(|| parse(&bytes, &Format::OnePux, NOW));
let r = r.unwrap_or_else(|_| panic!("{what}: panicked"));
if what == "wrong types" {
// Tolerated: odd field types are ignored, the item is still imported.
assert_eq!(r.unwrap().items.len(), 1, "{what}");
} else {
assert!(r.is_err(), "{what}: expected an error");
}
}
for (bytes, what) in [
(&b""[..], "empty csv"),
(b"\xff\xfe", "not utf-8"),
(b"foo,bar\n1,2\n", "unknown header"),
(b"title,password\n\"open,1\n", "unterminated quote"),
] {
let r = std::panic::catch_unwind(|| parse(bytes, &Format::Csv, NOW)).unwrap_or_else(|_| panic!("{what}"));
assert!(r.is_err(), "{what}");
}
for (bytes, what) in [(&b"KVEX"[..], "short export"), (&[0u8; 200][..], "no magic")] {
let r = parse(bytes, &Format::Encrypted { passphrase: "a long passphrase".into() }, NOW);
assert!(matches!(r, Err(ImportError::Malformed(_))), "{what}");
}
}
#[test]
fn csv_1password() {
let p = parse(include_bytes!("fixtures/1password8.csv"), &Format::Csv, NOW).unwrap();
assert_eq!(p.items.len(), 3);
let git = by_title(&p, "Example Git");
assert_eq!(git.username.as_deref(), Some("[email protected]"));
assert_eq!(git.password.as_deref(), Some("correct-horse-battery-staple"));
assert_eq!(git.urls, vec!["https://git.example.com/login"]);
assert!(git.totp.as_deref().unwrap().contains("secret=JBSWY3DPEHPK3PXP"));
assert!(git.favorite);
assert_eq!(git.tags, vec!["dev", "work"]);
assert_eq!(git.notes.as_deref(), Some("Main code hosting account.\nSecond line, with a comma and \"quotes\"."));
let forum = by_title(&p, "Old Forum");
assert_eq!(forum.password.as_deref(), Some(" leading-space-pw"));
assert_eq!(forum.tags, vec!["Archived"]);
assert!(p.warnings.iter().any(|w| w.contains("1 archived")));
let router = by_title(&p, "Router");
assert!(router.urls.is_empty());
assert_eq!(router.notes.as_deref(), Some("Router admin"));
}
#[test]
fn csv_generic_bom_crlf_extra_columns() {
let p = parse(include_bytes!("fixtures/generic-bom-crlf.csv"), &Format::Csv, NOW).unwrap();
assert_eq!(p.items.len(), 2);
let mail = by_title(&p, "Mail");
assert_eq!(mail.urls, vec!["mail.example.com"]);
assert_eq!(mail.username.as_deref(), Some("alex"));
assert_eq!(mail.totp.as_deref(), Some("JBSWY3DPEHPK3PXP"));
assert_eq!(
mail.notes.as_deref(),
Some("multi\r\nline\n\n— Imported fields —\nSecurity Question: Synthetic Fluffy")
);
// No title: falls back to the URL host.
let bank = by_title(&p, "bank.example.com");
assert_eq!(bank.password.as_deref(), Some("bank-synthetic-pw"));
}
#[test]
fn csv_ragged_rows() {
let p = parse(b"title,password\nA,1,surplus\nB\n", &Format::Csv, NOW).unwrap();
assert_eq!(by_title(&p, "A").notes.as_deref(), Some("— Imported fields —\nColumn 3: surplus"));
assert!(by_title(&p, "B").password.is_none());
}
#[test]
fn encrypted_export_round_trip() {
let source = parse(&fixture_1pux(), &Format::OnePux, NOW).unwrap();
let mut items = source.items.clone();
items[0].passkeys.push(kv_model::Passkey {
credential_id: vec![1, 2, 3],
rp_id: "example.com".into(),
user_handle: vec![4],
user_name: "alex".into(),
private_key: vec![5; 32],
sign_count: 7,
created_at: 1,
});
items[0].android_apps.push("com.example.git".into());
let blob = export::encrypt(&items, "export passphrase 1", NOW).unwrap();
assert!(export::looks_like_export(&blob));
assert_eq!(&blob[..4], b"KVEX");
// Ciphertext does not contain plaintext.
assert!(!blob.windows(12).any(|w| w == b"correct-hors"));
let back = parse(&blob, &Format::Encrypted { passphrase: " export passphrase 1 ".into() }, NOW).unwrap();
assert_eq!(back.items.len(), items.len());
for (a, b) in items.iter().zip(&back.items) {
assert_ne!(a.id, b.id, "restored items get fresh ids");
let mut b = b.clone();
b.id = a.id;
assert_eq!(*a, b);
}
assert_eq!(
parse(&blob, &Format::Encrypted { passphrase: "wrong passphrase".into() }, NOW).unwrap_err(),
ImportError::BadPassphrase
);
// Header is authenticated: lowering t (or any header byte) breaks decryption or is refused.
let mut tampered = blob.clone();
tampered[10] = 4;
assert_eq!(
parse(&tampered, &Format::Encrypted { passphrase: "export passphrase 1".into() }, NOW).unwrap_err(),
ImportError::BadPassphrase
);
let mut weak = blob.clone();
weak[10] = 1;
assert!(matches!(
parse(&weak, &Format::Encrypted { passphrase: "export passphrase 1".into() }, NOW),
Err(ImportError::Unsupported(_))
));
let mut future = blob;
future[4] = 2;
assert!(matches!(
parse(&future, &Format::Encrypted { passphrase: "export passphrase 1".into() }, NOW),
Err(ImportError::Unsupported(_))
));
}