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,185 @@
|
||||
//! CSV import: 1Password's CSV export (`Title,Url,Username,Password,OTPAuth,Favorite,Archived,Tags,Notes`)
|
||||
//! and any CSV whose header names columns like `title,url,username,password,otp,notes`
|
||||
//! (case-insensitive; see [`column_for`]). Columns with no field of their own go into the notes.
|
||||
//!
|
||||
//! RFC 4180 parsing: quoted fields, `""` escapes, embedded newlines, CRLF or LF, a UTF-8 BOM.
|
||||
|
||||
use kv_model::ItemKind;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::{malformed, new_item, set_opt, Extras, ImportError, Limits, Parsed, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Column {
|
||||
Title,
|
||||
Url,
|
||||
Username,
|
||||
Password,
|
||||
Otp,
|
||||
Notes,
|
||||
Favorite,
|
||||
Tags,
|
||||
Archived,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Header aliases (lowercased, non-alphanumerics removed). Covers 1Password 7/8, Bitwarden, Chrome.
|
||||
fn column_for(header: &str) -> Column {
|
||||
let h: String = header.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
||||
match h.as_str() {
|
||||
"title" | "name" => Column::Title,
|
||||
"url" | "urls" | "website" | "loginuri" | "loginurl" | "uri" => Column::Url,
|
||||
"username" | "loginusername" | "user" | "login" => Column::Username,
|
||||
"password" | "loginpassword" => Column::Password,
|
||||
"otp" | "otpauth" | "totp" | "logintotp" | "onetimepassword" => Column::Otp,
|
||||
"notes" | "note" | "notesplain" | "comments" => Column::Notes,
|
||||
"favorite" | "favourite" | "fav" => Column::Favorite,
|
||||
"tags" | "folder" => Column::Tags,
|
||||
"archived" => Column::Archived,
|
||||
_ => Column::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed CSV whose cells are wiped on drop.
|
||||
pub struct Records(pub Vec<Vec<String>>);
|
||||
|
||||
impl Drop for Records {
|
||||
fn drop(&mut self) {
|
||||
for row in &mut self.0 {
|
||||
row.iter_mut().for_each(Zeroize::zeroize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 4180 reader. Blank lines are skipped. An unterminated quote is an error.
|
||||
pub fn read_records(text: &str) -> Result<Records> {
|
||||
let mut rows = Records(vec![]);
|
||||
let mut row: Vec<String> = vec![];
|
||||
let mut field = String::new();
|
||||
let mut in_quotes = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if in_quotes {
|
||||
match c {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
field.push('"');
|
||||
}
|
||||
'"' => in_quotes = false,
|
||||
_ => field.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if field.is_empty() => in_quotes = true,
|
||||
',' => row.push(std::mem::take(&mut field)),
|
||||
'\r' if chars.peek() == Some(&'\n') => {}
|
||||
'\n' | '\r' => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
push_row(&mut rows.0, std::mem::take(&mut row));
|
||||
}
|
||||
_ => field.push(c),
|
||||
}
|
||||
}
|
||||
if in_quotes {
|
||||
field.zeroize();
|
||||
row.iter_mut().for_each(Zeroize::zeroize);
|
||||
return Err(malformed("CSV has an unterminated quoted field"));
|
||||
}
|
||||
if !field.is_empty() || !row.is_empty() {
|
||||
row.push(field);
|
||||
push_row(&mut rows.0, row);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn push_row(rows: &mut Vec<Vec<String>>, mut row: Vec<String>) {
|
||||
if row.iter().all(|f| f.trim().is_empty()) {
|
||||
row.iter_mut().for_each(Zeroize::zeroize);
|
||||
} else {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
fn truthy(s: &str) -> bool {
|
||||
matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "y" | "x")
|
||||
}
|
||||
|
||||
pub fn parse(bytes: &[u8], now: i64, limits: &Limits) -> Result<Parsed> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| malformed("CSV is not UTF-8"))?;
|
||||
let records = read_records(text)?;
|
||||
let Some((header, rows)) = records.0.split_first() else { return Err(malformed("CSV is empty")) };
|
||||
let columns: Vec<Column> = header.iter().map(|h| column_for(h)).collect();
|
||||
let has = |c: Column| columns.contains(&c);
|
||||
if !(has(Column::Title) || has(Column::Url))
|
||||
|| !(has(Column::Password) || has(Column::Username) || has(Column::Notes))
|
||||
{
|
||||
return Err(ImportError::Unsupported(
|
||||
"CSV header must name a title or url column and a username, password or notes column".into(),
|
||||
));
|
||||
}
|
||||
limits.check_items(rows.len())?;
|
||||
|
||||
let mut out = Parsed::default();
|
||||
let mut archived = 0u32;
|
||||
for row in rows {
|
||||
let cell = |c: Column| columns.iter().position(|x| *x == c).and_then(|i| row.get(i)).map_or("", |s| s.as_str());
|
||||
let mut item = new_item(ItemKind::Login, cell(Column::Title), now);
|
||||
if cell(Column::Title).trim().is_empty() {
|
||||
if let Some(host) =
|
||||
url::Url::parse(cell(Column::Url).trim()).ok().and_then(|u| u.host_str().map(String::from))
|
||||
{
|
||||
item.title = host;
|
||||
}
|
||||
}
|
||||
set_opt(&mut item.username, cell(Column::Username));
|
||||
set_opt(&mut item.password, cell(Column::Password));
|
||||
set_opt(&mut item.totp, cell(Column::Otp).trim());
|
||||
set_opt(&mut item.notes, cell(Column::Notes));
|
||||
item.urls = cell(Column::Url).lines().map(str::trim).filter(|u| !u.is_empty()).map(String::from).collect();
|
||||
item.favorite = truthy(cell(Column::Favorite));
|
||||
item.tags =
|
||||
cell(Column::Tags).split([',', ';']).map(str::trim).filter(|t| !t.is_empty()).map(String::from).collect();
|
||||
if truthy(cell(Column::Archived)) {
|
||||
item.tags.push("Archived".into());
|
||||
archived += 1;
|
||||
}
|
||||
let mut extras = Extras::default();
|
||||
for (i, value) in row.iter().enumerate() {
|
||||
match columns.get(i) {
|
||||
Some(Column::Other) => extras.push(&header[i], value),
|
||||
Some(_) => {}
|
||||
None => extras.push(&format!("Column {}", i + 1), value),
|
||||
}
|
||||
}
|
||||
extras.append_to(&mut item, "— Imported fields —");
|
||||
out.items.push(item);
|
||||
}
|
||||
if archived > 0 {
|
||||
out.warnings.push(format!("{archived} archived item(s) were imported with the tag \"Archived\""));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rfc4180() {
|
||||
let r = read_records("a,\"b,c\",\"d \"\"q\"\"\"\r\n\r\n\"multi\nline\",,x\n").unwrap();
|
||||
assert_eq!(r.0, vec![vec!["a", "b,c", "d \"q\""], vec!["multi\nline", "", "x"]]);
|
||||
let r = read_records("a,b").unwrap();
|
||||
assert_eq!(r.0, vec![vec!["a", "b"]]);
|
||||
assert!(read_records("a,\"open").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_aliases() {
|
||||
assert_eq!(column_for("Login_URI"), Column::Url);
|
||||
assert_eq!(column_for("OTPAuth"), Column::Otp);
|
||||
assert_eq!(column_for(" Title "), Column::Title);
|
||||
assert_eq!(column_for("Security question"), Column::Other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Duplicate detection: an imported item duplicates an existing one (or an earlier one in the
|
||||
//! same import) when (title, username, primary URL host) match, case-insensitively.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use kv_model::Item;
|
||||
|
||||
pub const IMPORTED_SUFFIX: &str = " (imported)";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DuplicatePolicy {
|
||||
/// Leave the existing item alone and drop the imported one.
|
||||
Skip,
|
||||
/// Import it anyway, with `" (imported)"` appended to the title.
|
||||
AddSuffix,
|
||||
}
|
||||
|
||||
pub type Key = (String, String, String);
|
||||
|
||||
/// Host of the first URL, lowercased, without a leading `www.`. Empty when there is no usable URL.
|
||||
pub fn primary_host(item: &Item) -> String {
|
||||
let Some(raw) = item.urls.first().map(|u| u.trim()) else { return String::new() };
|
||||
let parsed = url::Url::parse(raw).ok().filter(|u| u.host_str().is_some()).or_else(|| {
|
||||
// Bare "example.com/login" from CSVs.
|
||||
url::Url::parse(&format!("https://{raw}")).ok()
|
||||
});
|
||||
let host = parsed.and_then(|u| u.host_str().map(str::to_lowercase)).unwrap_or_else(|| raw.to_lowercase());
|
||||
host.strip_prefix("www.").map(str::to_string).unwrap_or(host)
|
||||
}
|
||||
|
||||
pub fn key(item: &Item) -> Key {
|
||||
(item.title.trim().to_lowercase(), item.username.as_deref().unwrap_or("").trim().to_lowercase(), primary_host(item))
|
||||
}
|
||||
|
||||
pub struct Plan {
|
||||
/// Items to store, in input order.
|
||||
pub to_add: Vec<Item>,
|
||||
/// Indices into the input of the duplicates (skipped or renamed).
|
||||
pub duplicates: Vec<usize>,
|
||||
pub skipped: u32,
|
||||
pub renamed: u32,
|
||||
}
|
||||
|
||||
/// Decides what to store. `existing` are the keys of the items already in the vault.
|
||||
pub fn plan(items: Vec<Item>, existing: impl IntoIterator<Item = Key>, policy: DuplicatePolicy) -> Plan {
|
||||
let mut seen: HashSet<Key> = existing.into_iter().collect();
|
||||
let mut out = Plan { to_add: Vec::with_capacity(items.len()), duplicates: vec![], skipped: 0, renamed: 0 };
|
||||
for (idx, mut item) in items.into_iter().enumerate() {
|
||||
let k = key(&item);
|
||||
if !seen.contains(&k) {
|
||||
seen.insert(k);
|
||||
out.to_add.push(item);
|
||||
continue;
|
||||
}
|
||||
out.duplicates.push(idx);
|
||||
match policy {
|
||||
DuplicatePolicy::Skip => out.skipped += 1,
|
||||
DuplicatePolicy::AddSuffix => {
|
||||
item.title.push_str(IMPORTED_SUFFIX);
|
||||
// Importing the same file twice still yields only one "(imported)" copy per key.
|
||||
while !seen.insert(key(&item)) {
|
||||
item.title.push_str(IMPORTED_SUFFIX);
|
||||
}
|
||||
out.renamed += 1;
|
||||
out.to_add.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn login(title: &str, user: &str, url: &str) -> Item {
|
||||
let mut i = Item::new_login(title, 0);
|
||||
i.username = Some(user.into());
|
||||
if !url.is_empty() {
|
||||
i.urls = vec![url.into()];
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_normalization() {
|
||||
assert_eq!(primary_host(&login("a", "b", "https://WWW.Example.com/login?x=1")), "example.com");
|
||||
assert_eq!(primary_host(&login("a", "b", "example.com/login")), "example.com");
|
||||
assert_eq!(primary_host(&login("a", "b", "")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_and_suffix() {
|
||||
let existing = [key(&login("GitHub", "me", "https://github.com"))];
|
||||
let items = vec![
|
||||
login("github", "ME", "https://github.com/login"),
|
||||
login("GitHub", "other", "https://github.com"),
|
||||
login("Mail", "me", "https://mail.example"),
|
||||
login("Mail", "me", "https://mail.example/inbox"),
|
||||
];
|
||||
let p = plan(items.clone(), existing.clone(), DuplicatePolicy::Skip);
|
||||
assert_eq!((p.to_add.len(), p.skipped, p.renamed), (2, 2, 0));
|
||||
assert_eq!(p.duplicates, vec![0, 3]);
|
||||
|
||||
let p = plan(items, existing, DuplicatePolicy::AddSuffix);
|
||||
assert_eq!((p.to_add.len(), p.skipped, p.renamed), (4, 0, 2));
|
||||
assert_eq!(p.to_add[0].title, "github (imported)");
|
||||
assert_eq!(p.to_add[3].title, "Mail (imported)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Passphrase-encrypted export (`.kvex`, docs/09 "Encrypted export").
|
||||
//!
|
||||
//! ```text
|
||||
//! magic "KVEX" | version u8 = 1 | alg u8 = 1 | m_kib u32le | t u32le | p u32le | salt[16] | nonce[24] | ciphertext
|
||||
//! key = Argon2id(NFKD(trim(passphrase)), salt, m_kib, t, p, L = 32)
|
||||
//! ciphertext = XChaCha20-Poly1305(key, nonce, aad = all header bytes, pad(JSON))
|
||||
//! JSON = {"format": "kestrove-export", "version": 1, "exported_at": <unix s>, "items": [Item…]}
|
||||
//! ```
|
||||
//!
|
||||
//! `alg = 1` names the Argon2id + XChaCha20-Poly1305 suite. Readers refuse KDF parameters below
|
||||
//! the account KDF minimum (m ≥ 64 MiB, t ≥ 3) and above a sanity cap (so a crafted file cannot
|
||||
//! make the reader allocate gigabytes). Plaintext export is deliberately not offered.
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit, Payload},
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use kv_crypto::{aead, random_bytes};
|
||||
use kv_model::Item;
|
||||
use kv_proto::{MIN_KDF_M_KIB, MIN_KDF_T};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{malformed, ImportError, Result};
|
||||
|
||||
pub const MAGIC: &[u8; 4] = b"KVEX";
|
||||
pub const VERSION: u8 = 1;
|
||||
pub const ALG_ARGON2ID_XCHACHA20POLY1305: u8 = 1;
|
||||
pub const HEADER_LEN: usize = 4 + 1 + 1 + 12 + 16 + 24;
|
||||
pub const MIN_PASSPHRASE_CHARS: usize = 10;
|
||||
const FORMAT_NAME: &str = "kestrove-export";
|
||||
|
||||
/// Upper bounds a reader accepts (1 GiB, 10 passes, 16 lanes).
|
||||
const MAX_M_KIB: u32 = 1024 * 1024;
|
||||
const MAX_T: u32 = 10;
|
||||
const MAX_P: u32 = 16;
|
||||
|
||||
/// Argon2id cost for new exports: the account KDF default (RFC 9106 second option).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct KdfCost {
|
||||
pub m_kib: u32,
|
||||
pub t: u32,
|
||||
pub p: u32,
|
||||
}
|
||||
|
||||
impl Default for KdfCost {
|
||||
fn default() -> Self {
|
||||
Self { m_kib: 64 * 1024, t: 3, p: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
impl KdfCost {
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.m_kib < MIN_KDF_M_KIB || self.t < MIN_KDF_T || self.p == 0 {
|
||||
return Err(ImportError::Unsupported("KDF parameters below the minimum".into()));
|
||||
}
|
||||
if self.m_kib > MAX_M_KIB || self.t > MAX_T || self.p > MAX_P {
|
||||
return Err(ImportError::Unsupported("KDF parameters above the allowed maximum".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DocOut<'a> {
|
||||
format: &'a str,
|
||||
version: u32,
|
||||
exported_at: i64,
|
||||
items: &'a [Item],
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DocIn {
|
||||
format: String,
|
||||
version: u32,
|
||||
items: Vec<Item>,
|
||||
}
|
||||
|
||||
fn derive_key(passphrase: &str, salt: &[u8; 16], cost: KdfCost) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let normalized = Zeroizing::new(passphrase.trim().nfkd().collect::<String>());
|
||||
let params = Params::new(cost.m_kib, cost.t, cost.p, Some(32)).map_err(|_| ImportError::Crypto)?;
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
|
||||
.hash_password_into(normalized.as_bytes(), salt, key.as_mut())
|
||||
.map_err(|_| ImportError::Crypto)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn header(cost: KdfCost, salt: &[u8; 16], nonce: &[u8; 24]) -> Vec<u8> {
|
||||
let mut h = Vec::with_capacity(HEADER_LEN);
|
||||
h.extend_from_slice(MAGIC);
|
||||
h.push(VERSION);
|
||||
h.push(ALG_ARGON2ID_XCHACHA20POLY1305);
|
||||
h.extend_from_slice(&cost.m_kib.to_le_bytes());
|
||||
h.extend_from_slice(&cost.t.to_le_bytes());
|
||||
h.extend_from_slice(&cost.p.to_le_bytes());
|
||||
h.extend_from_slice(salt);
|
||||
h.extend_from_slice(nonce);
|
||||
h
|
||||
}
|
||||
|
||||
/// Encrypts `items` under `passphrase` with the default cost.
|
||||
pub fn encrypt(items: &[Item], passphrase: &str, now: i64) -> Result<Vec<u8>> {
|
||||
encrypt_with_cost(items, passphrase, now, KdfCost::default())
|
||||
}
|
||||
|
||||
pub fn encrypt_with_cost(items: &[Item], passphrase: &str, now: i64, cost: KdfCost) -> Result<Vec<u8>> {
|
||||
if passphrase.trim().chars().count() < MIN_PASSPHRASE_CHARS {
|
||||
return Err(ImportError::WeakPassphrase { min: MIN_PASSPHRASE_CHARS });
|
||||
}
|
||||
cost.validate()?;
|
||||
let salt = random_bytes::<16>();
|
||||
let nonce = random_bytes::<24>();
|
||||
let key = derive_key(passphrase, &salt, cost)?;
|
||||
let json = Zeroizing::new(
|
||||
serde_json::to_vec(&DocOut { format: FORMAT_NAME, version: 1, exported_at: now, items }).map_err(malformed)?,
|
||||
);
|
||||
let padded = aead::pad(&json);
|
||||
let mut out = header(cost, &salt, &nonce);
|
||||
let ct = XChaCha20Poly1305::new(Key::from_slice(key.as_ref()))
|
||||
.encrypt(XNonce::from_slice(&nonce), Payload { msg: &padded, aad: &out })
|
||||
.map_err(|_| ImportError::Crypto)?;
|
||||
out.extend_from_slice(&ct);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// True if `bytes` starts like an encrypted export.
|
||||
pub fn looks_like_export(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(MAGIC)
|
||||
}
|
||||
|
||||
pub fn decrypt(bytes: &[u8], passphrase: &str) -> Result<Vec<Item>> {
|
||||
if bytes.len() < HEADER_LEN + 16 || !looks_like_export(bytes) {
|
||||
return Err(malformed("not a Kestrove encrypted export"));
|
||||
}
|
||||
let (hdr, ct) = bytes.split_at(HEADER_LEN);
|
||||
if hdr[4] != VERSION || hdr[5] != ALG_ARGON2ID_XCHACHA20POLY1305 {
|
||||
return Err(ImportError::Unsupported(format!("export version {} / alg {}", hdr[4], hdr[5])));
|
||||
}
|
||||
let u32_at = |o: usize| u32::from_le_bytes([hdr[o], hdr[o + 1], hdr[o + 2], hdr[o + 3]]);
|
||||
let cost = KdfCost { m_kib: u32_at(6), t: u32_at(10), p: u32_at(14) };
|
||||
cost.validate()?;
|
||||
let mut salt = [0u8; 16];
|
||||
salt.copy_from_slice(&hdr[18..34]);
|
||||
let key = derive_key(passphrase, &salt, cost)?;
|
||||
let padded = Zeroizing::new(
|
||||
XChaCha20Poly1305::new(Key::from_slice(key.as_ref()))
|
||||
.decrypt(XNonce::from_slice(&hdr[34..58]), Payload { msg: ct, aad: hdr })
|
||||
.map_err(|_| ImportError::BadPassphrase)?,
|
||||
);
|
||||
let json = aead::unpad(&padded).map_err(|_| malformed("export padding"))?;
|
||||
let doc: DocIn = serde_json::from_slice(&json).map_err(|e| malformed(format!("export JSON: {e}")))?;
|
||||
if doc.format != FORMAT_NAME || doc.version != 1 {
|
||||
return Err(ImportError::Unsupported(format!("{} version {}", doc.format, doc.version)));
|
||||
}
|
||||
Ok(doc.items)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_layout() {
|
||||
assert_eq!(header(KdfCost::default(), &[0; 16], &[0; 24]).len(), HEADER_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_weak_and_huge_params() {
|
||||
assert!(KdfCost { m_kib: 1024, t: 3, p: 1 }.validate().is_err());
|
||||
assert!(KdfCost { m_kib: 64 * 1024, t: 1, p: 1 }.validate().is_err());
|
||||
assert!(KdfCost { m_kib: 4 * 1024 * 1024, t: 3, p: 1 }.validate().is_err());
|
||||
assert!(KdfCost::default().validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_passphrase() {
|
||||
assert_eq!(encrypt(&[], "short", 0).unwrap_err(), ImportError::WeakPassphrase { min: 10 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Import from other password managers and passphrase-encrypted export
|
||||
//! (kestrove-spec/docs/09-import-export.md).
|
||||
//!
|
||||
//! This crate is pure: it turns bytes into [`Item`]s (and back, for the encrypted export).
|
||||
//! Storing, sealing and seq-stamping happen in `kv-ffi`, through the normal save path.
|
||||
//!
|
||||
//! Rules:
|
||||
//! * Untrusted input never panics: every parser returns [`ImportError`].
|
||||
//! * Every input is size-capped before parsing ([`Limits`]); ZIP entries are capped on
|
||||
//! their *decompressed* size and the archive on its entry count.
|
||||
//! * Plaintext buffers are zeroized ([`zeroize::Zeroizing`], wiped JSON values, wiped CSV cells).
|
||||
//! * Nothing is silently dropped: fields without a home in [`Item`] go into the notes, and what
|
||||
//! cannot be imported at all (attachments, password history) is reported in the warnings.
|
||||
|
||||
pub mod csv;
|
||||
pub mod dedup;
|
||||
pub mod export;
|
||||
pub mod onepux;
|
||||
|
||||
use kv_model::{Item, ItemKind};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
pub use dedup::{plan, DuplicatePolicy, Plan};
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ImportError {
|
||||
#[error("input is larger than the {limit}-byte limit")]
|
||||
TooLarge { limit: u64 },
|
||||
#[error("archive has more than {limit} entries")]
|
||||
TooManyEntries { limit: usize },
|
||||
#[error("malformed input: {0}")]
|
||||
Malformed(String),
|
||||
#[error("wrong passphrase or corrupted export")]
|
||||
BadPassphrase,
|
||||
#[error("export passphrase must be at least {min} characters")]
|
||||
WeakPassphrase { min: usize },
|
||||
#[error("unsupported export: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("crypto failure")]
|
||||
Crypto,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ImportError>;
|
||||
|
||||
fn malformed(e: impl std::fmt::Display) -> ImportError {
|
||||
ImportError::Malformed(e.to_string())
|
||||
}
|
||||
|
||||
/// Input caps. [`Limits::default`] is what the apps get; tests use smaller ones.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Limits {
|
||||
/// Raw input (zip, CSV or encrypted export).
|
||||
pub max_input: u64,
|
||||
/// Decompressed size of `export.data` inside a `.1pux`.
|
||||
pub max_decompressed: u64,
|
||||
/// Entries in a `.1pux` archive (including `files/…`).
|
||||
pub max_entries: usize,
|
||||
/// Items produced by one import.
|
||||
pub max_items: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_input: 100 * 1024 * 1024,
|
||||
max_decompressed: 64 * 1024 * 1024,
|
||||
max_entries: 10_000,
|
||||
max_items: 100_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
fn check_input(&self, len: usize) -> Result<()> {
|
||||
if len as u64 > self.max_input {
|
||||
return Err(ImportError::TooLarge { limit: self.max_input });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_items(&self, n: usize) -> Result<()> {
|
||||
if n > self.max_items {
|
||||
return Err(ImportError::Malformed(format!("more than {} items", self.max_items)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// What to parse.
|
||||
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
|
||||
pub enum Format {
|
||||
/// 1Password Unencrypted Export (`.1pux`, a ZIP).
|
||||
OnePux,
|
||||
/// 1Password CSV or a generic `title,url,username,password,otp,notes` CSV.
|
||||
Csv,
|
||||
/// A Kestrove encrypted export ([`export`]).
|
||||
Encrypted { passphrase: String },
|
||||
}
|
||||
|
||||
/// Result of parsing. Items have fresh ids and `seq = 1`; nothing is stored yet.
|
||||
#[derive(Default)]
|
||||
pub struct Parsed {
|
||||
pub items: Vec<Item>,
|
||||
/// Human-readable, may name items or files, never contains a secret value.
|
||||
pub warnings: Vec<String>,
|
||||
pub attachments_skipped: u32,
|
||||
}
|
||||
|
||||
/// Counts only: never prints item contents.
|
||||
impl std::fmt::Debug for Parsed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Parsed")
|
||||
.field("items", &self.items.len())
|
||||
.field("warnings", &self.warnings)
|
||||
.field("attachments_skipped", &self.attachments_skipped)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Parsed {
|
||||
pub fn count(&self, kind: ItemKind) -> u32 {
|
||||
self.items.iter().filter(|i| i.kind == kind).count() as u32
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(bytes: &[u8], format: &Format, now: i64) -> Result<Parsed> {
|
||||
parse_with_limits(bytes, format, now, &Limits::default())
|
||||
}
|
||||
|
||||
pub fn parse_with_limits(bytes: &[u8], format: &Format, now: i64, limits: &Limits) -> Result<Parsed> {
|
||||
limits.check_input(bytes.len())?;
|
||||
let parsed = match format {
|
||||
Format::OnePux => onepux::parse(bytes, now, limits)?,
|
||||
Format::Csv => csv::parse(bytes, now, limits)?,
|
||||
Format::Encrypted { passphrase } => {
|
||||
let mut items = export::decrypt(bytes, passphrase)?;
|
||||
limits.check_items(items.len())?;
|
||||
for i in &mut items {
|
||||
// A restored item is a new item in this vault: new id, fresh edit counter.
|
||||
i.id = Uuid::new_v4();
|
||||
i.seq = 1;
|
||||
}
|
||||
Parsed { items, ..Default::default() }
|
||||
}
|
||||
};
|
||||
limits.check_items(parsed.items.len())?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// A blank item of `kind`.
|
||||
pub(crate) fn new_item(kind: ItemKind, title: &str, now: i64) -> Item {
|
||||
let title = title.trim();
|
||||
let mut item = Item::new_login(if title.is_empty() { "Untitled" } else { title }, now);
|
||||
item.kind = kind;
|
||||
item
|
||||
}
|
||||
|
||||
/// Collects `label: value` lines that have no field of their own and appends them to the notes.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Extras {
|
||||
lines: String,
|
||||
}
|
||||
|
||||
impl Extras {
|
||||
pub fn heading(&mut self, title: &str) {
|
||||
let title = title.trim();
|
||||
if !title.is_empty() {
|
||||
if !self.lines.is_empty() {
|
||||
self.lines.push('\n');
|
||||
}
|
||||
self.lines.push('[');
|
||||
self.lines.push_str(title);
|
||||
self.lines.push_str("]\n");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, label: &str, value: &str) {
|
||||
if value.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let label = label.trim();
|
||||
self.lines.push_str(if label.is_empty() { "Field" } else { label });
|
||||
self.lines.push_str(": ");
|
||||
self.lines.push_str(value);
|
||||
self.lines.push('\n');
|
||||
}
|
||||
|
||||
pub fn has_values(&self) -> bool {
|
||||
self.lines.contains(": ")
|
||||
}
|
||||
|
||||
/// Appends the collected lines under `banner` (only if any value was pushed).
|
||||
pub fn append_to(mut self, item: &mut Item, banner: &str) {
|
||||
if self.has_values() {
|
||||
let mut notes = item.notes.take().unwrap_or_default();
|
||||
let trimmed_len = notes.trim_end().len();
|
||||
notes.truncate(trimmed_len);
|
||||
if !notes.is_empty() {
|
||||
notes.push_str("\n\n");
|
||||
}
|
||||
notes.push_str(banner);
|
||||
notes.push('\n');
|
||||
notes.push_str(self.lines.trim_end());
|
||||
item.notes = Some(notes);
|
||||
}
|
||||
self.lines.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Extras {
|
||||
fn drop(&mut self) {
|
||||
self.lines.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts `s` into `slot` unless it is blank. Kept verbatim: a password may start or end with a space.
|
||||
pub(crate) fn set_opt(slot: &mut Option<String>, s: &str) {
|
||||
if !s.trim().is_empty() {
|
||||
*slot = Some(s.to_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 1Password Unencrypted Export (`.1pux`): a ZIP holding `export.attributes`, `export.data`
|
||||
//! (JSON: `accounts[].vaults[].items[]`) and `files/` (attachments, not imported).
|
||||
//!
|
||||
//! Mapping (docs/09): Login 001 and Password 005 → login; Credit Card 002 → card; Secure Note 003
|
||||
//! → secure note; Identity 004 → identity; every other category → secure note. Fields without a
|
||||
//! home in `Item` (card and identity fields, custom sections, extra form fields) are appended to
|
||||
//! the notes as `Label: value` lines under a "— Imported from 1Password —" banner.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use kv_model::{Item, ItemKind};
|
||||
use serde_json::Value;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::{malformed, new_item, set_opt, Extras, ImportError, Limits, Parsed, Result};
|
||||
|
||||
const DATA_ENTRY: &str = "export.data";
|
||||
const BANNER: &str = "— Imported from 1Password —";
|
||||
|
||||
/// JSON whose strings are wiped on drop.
|
||||
struct Wiped(Value);
|
||||
|
||||
impl Drop for Wiped {
|
||||
fn drop(&mut self) {
|
||||
wipe(&mut self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn wipe(v: &mut Value) {
|
||||
match v {
|
||||
Value::String(s) => s.zeroize(),
|
||||
Value::Array(a) => a.iter_mut().for_each(wipe),
|
||||
Value::Object(m) => m.values_mut().for_each(wipe),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `export.data` with the entry-count and decompressed-size caps.
|
||||
pub fn read_export_data(bytes: &[u8], limits: &Limits) -> Result<(Zeroizing<Vec<u8>>, usize)> {
|
||||
let mut zip =
|
||||
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| malformed(format!("not a .1pux archive: {e}")))?;
|
||||
if zip.len() > limits.max_entries {
|
||||
return Err(ImportError::TooManyEntries { limit: limits.max_entries });
|
||||
}
|
||||
let files =
|
||||
zip.file_names().filter_map(|n| n.ok()).filter(|n| n.starts_with("files/") && !n.ends_with('/')).count();
|
||||
let entry = zip.by_name(DATA_ENTRY).map_err(|_| malformed("export.data is missing"))?;
|
||||
if entry.size() > limits.max_decompressed {
|
||||
return Err(ImportError::TooLarge { limit: limits.max_decompressed });
|
||||
}
|
||||
// Never trust the declared size: stop reading one byte past the cap.
|
||||
let mut out = Zeroizing::new(Vec::new());
|
||||
entry
|
||||
.take(limits.max_decompressed + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| malformed(format!("export.data: {e}")))?;
|
||||
if out.len() as u64 > limits.max_decompressed {
|
||||
return Err(ImportError::TooLarge { limit: limits.max_decompressed });
|
||||
}
|
||||
Ok((out, files))
|
||||
}
|
||||
|
||||
pub fn parse(bytes: &[u8], now: i64, limits: &Limits) -> Result<Parsed> {
|
||||
let (data, files_in_archive) = read_export_data(bytes, limits)?;
|
||||
let root = Wiped(serde_json::from_slice(&data).map_err(|e| malformed(format!("export.data: {e}")))?);
|
||||
let accounts =
|
||||
root.0.get("accounts").and_then(Value::as_array).ok_or_else(|| malformed("export.data has no accounts"))?;
|
||||
|
||||
let vault_count: usize =
|
||||
accounts.iter().map(|a| a.get("vaults").and_then(Value::as_array).map_or(0, Vec::len)).sum();
|
||||
let mut ctx = Ctx { out: Parsed::default(), now, password_history: 0, other_category: 0, trashed: 0, archived: 0 };
|
||||
for account in accounts {
|
||||
for vault in account.get("vaults").and_then(Value::as_array).into_iter().flatten() {
|
||||
let vault_name = str_at(vault, &["attrs", "name"]);
|
||||
for raw in vault.get("items").and_then(Value::as_array).into_iter().flatten() {
|
||||
limits.check_items(ctx.out.items.len() + 1)?;
|
||||
if let Some(mut item) = ctx.item(raw)? {
|
||||
if vault_count > 1 && !vault_name.is_empty() {
|
||||
item.tags.push(vault_name.to_string());
|
||||
}
|
||||
ctx.out.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ctx { mut out, password_history, other_category, trashed, archived, .. } = ctx;
|
||||
if out.attachments_skipped == 0 && files_in_archive > 0 {
|
||||
out.attachments_skipped = files_in_archive as u32;
|
||||
out.warnings.push(format!(
|
||||
"{files_in_archive} file(s) in the archive were not imported (attachments are not supported yet)"
|
||||
));
|
||||
}
|
||||
if password_history > 0 {
|
||||
out.warnings.push(format!("{password_history} previous password(s) from password history were not imported"));
|
||||
}
|
||||
if other_category > 0 {
|
||||
out.warnings.push(format!("{other_category} item(s) of other categories were imported as secure notes"));
|
||||
}
|
||||
if archived > 0 {
|
||||
out.warnings.push(format!("{archived} archived item(s) were imported with the tag \"Archived\""));
|
||||
}
|
||||
if trashed > 0 {
|
||||
out.warnings.push(format!("{trashed} deleted item(s) were skipped"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct Ctx {
|
||||
out: Parsed,
|
||||
now: i64,
|
||||
password_history: u32,
|
||||
other_category: u32,
|
||||
trashed: u32,
|
||||
archived: u32,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn item(&mut self, raw: &Value) -> Result<Option<Item>> {
|
||||
if !raw.is_object() {
|
||||
return Err(malformed("item is not an object"));
|
||||
}
|
||||
let state = str_at(raw, &["state"]);
|
||||
if state == "deleted" || state == "trashed" {
|
||||
self.trashed += 1;
|
||||
return Ok(None);
|
||||
}
|
||||
let category = str_at(raw, &["categoryUuid"]);
|
||||
let kind = match category {
|
||||
"001" | "005" => ItemKind::Login,
|
||||
"002" => ItemKind::Card,
|
||||
"003" => ItemKind::SecureNote,
|
||||
"004" => ItemKind::Identity,
|
||||
_ => {
|
||||
self.other_category += 1;
|
||||
ItemKind::SecureNote
|
||||
}
|
||||
};
|
||||
let overview = raw.get("overview").unwrap_or(&Value::Null);
|
||||
let details = raw.get("details").unwrap_or(&Value::Null);
|
||||
let mut item = new_item(kind, str_at(overview, &["title"]), self.now);
|
||||
let title = item.title.clone();
|
||||
if let Some(t) = raw.get("createdAt").and_then(Value::as_i64) {
|
||||
item.created_at = t;
|
||||
}
|
||||
if let Some(t) = raw.get("updatedAt").and_then(Value::as_i64) {
|
||||
item.modified_at = t.max(item.created_at);
|
||||
}
|
||||
item.favorite = raw.get("favIndex").and_then(Value::as_i64).is_some_and(|f| f > 0);
|
||||
item.tags = overview
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(String::from)
|
||||
.collect();
|
||||
if state == "archived" {
|
||||
item.tags.push("Archived".into());
|
||||
self.archived += 1;
|
||||
}
|
||||
let mut urls: Vec<String> = overview
|
||||
.get("urls")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|u| u.get("url").and_then(Value::as_str))
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let main_url = str_at(overview, &["url"]);
|
||||
if !main_url.is_empty() && !urls.iter().any(|u| u == main_url) {
|
||||
urls.insert(0, main_url.to_string());
|
||||
}
|
||||
item.urls = urls;
|
||||
set_opt(&mut item.notes, str_at(details, &["notesPlain"]));
|
||||
|
||||
let mut extras = Extras::default();
|
||||
if category == "005" {
|
||||
set_opt(&mut item.password, str_at(details, &["password"]));
|
||||
} else if let Some(pw) = details.get("password").and_then(Value::as_str) {
|
||||
extras.push("Password", pw);
|
||||
}
|
||||
for f in details.get("loginFields").and_then(Value::as_array).into_iter().flatten() {
|
||||
let value = str_at(f, &["value"]);
|
||||
match str_at(f, &["designation"]) {
|
||||
"username" if item.username.is_none() && kind == ItemKind::Login => set_opt(&mut item.username, value),
|
||||
"password" if item.password.is_none() && kind == ItemKind::Login => set_opt(&mut item.password, value),
|
||||
_ => {
|
||||
let name = str_at(f, &["name"]);
|
||||
extras.push(if name.is_empty() { "Form field" } else { name }, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for section in details.get("sections").and_then(Value::as_array).into_iter().flatten() {
|
||||
let section_title = str_at(section, &["title"]);
|
||||
extras.heading(section_title);
|
||||
for field in section.get("fields").and_then(Value::as_array).into_iter().flatten() {
|
||||
self.field(&mut item, &mut extras, &title, field);
|
||||
}
|
||||
}
|
||||
if let Some(doc) = details.get("documentAttributes").filter(|d| d.is_object()) {
|
||||
self.attachment(&title, doc);
|
||||
}
|
||||
self.password_history += details.get("passwordHistory").and_then(Value::as_array).map_or(0, |h| h.len() as u32);
|
||||
extras.append_to(&mut item, BANNER);
|
||||
Ok(Some(item))
|
||||
}
|
||||
|
||||
fn field(&mut self, item: &mut Item, extras: &mut Extras, title: &str, field: &Value) {
|
||||
let label = {
|
||||
let t = str_at(field, &["title"]);
|
||||
if t.is_empty() {
|
||||
str_at(field, &["id"])
|
||||
} else {
|
||||
t
|
||||
}
|
||||
};
|
||||
let Some(value) = field.get("value").and_then(Value::as_object) else { return };
|
||||
for (vtype, v) in value {
|
||||
match vtype.as_str() {
|
||||
"totp" if item.totp.is_none() && v.as_str().is_some_and(|s| !s.trim().is_empty()) => {
|
||||
item.totp = v.as_str().map(|s| s.trim().to_string());
|
||||
}
|
||||
"file" => self.attachment(title, v),
|
||||
_ => {
|
||||
let text = Zeroizing::new(render(vtype, v));
|
||||
extras.push(label, &text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment(&mut self, title: &str, attrs: &Value) {
|
||||
self.out.attachments_skipped += 1;
|
||||
let name = str_at(attrs, &["fileName"]);
|
||||
self.out.warnings.push(format!(
|
||||
"\"{title}\": attachment \"{}\" was not imported (attachments are not supported yet)",
|
||||
if name.is_empty() { "unnamed" } else { name }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for a 1Password field value (`{"<type>": <value>}`).
|
||||
fn render(vtype: &str, v: &Value) -> String {
|
||||
match (vtype, v) {
|
||||
(_, Value::String(s)) => s.clone(),
|
||||
("date", Value::Number(n)) => n.as_i64().map(format_date).unwrap_or_else(|| n.to_string()),
|
||||
("monthYear", Value::Number(n)) => match n.as_i64() {
|
||||
Some(ym) if ym > 0 => format!("{:02}/{}", ym % 100, ym / 100),
|
||||
_ => n.to_string(),
|
||||
},
|
||||
("email", Value::Object(o)) => o.get("email_address").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
("address", Value::Object(o)) => ["street", "city", "state", "zip", "country"]
|
||||
.iter()
|
||||
.filter_map(|k| o.get(*k).and_then(Value::as_str))
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
("sshKey", Value::Object(o)) => o.get("privateKey").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
(_, Value::Null) => String::new(),
|
||||
(_, Value::Bool(b)) => b.to_string(),
|
||||
(_, Value::Number(n)) => n.to_string(),
|
||||
// Unknown structured value: keep it verbatim rather than drop it.
|
||||
(_, other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` (UTC) for a Unix timestamp in seconds.
|
||||
fn format_date(secs: i64) -> String {
|
||||
let z = secs.div_euclid(86_400) + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
fn str_at<'a>(v: &'a Value, path: &[&str]) -> &'a str {
|
||||
path.iter().try_fold(v, |v, k| v.get(k)).and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dates() {
|
||||
assert_eq!(format_date(0), "1970-01-01");
|
||||
assert_eq!(format_date(951_782_400), "2000-02-29");
|
||||
assert_eq!(format_date(-86_400), "1969-12-31");
|
||||
assert_eq!(render("monthYear", &serde_json::json!(202_712)), "12/2027");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user