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