From ab4aaedc45302886a3d68671e3f4a6cd0cef016b Mon Sep 17 00:00:00 2001 From: Hinton Date: Wed, 31 Jan 2024 12:43:09 +0100 Subject: [PATCH] Wrap up csv export --- Cargo.lock | 1 + crates/bitwarden-exporters/Cargo.toml | 1 + crates/bitwarden-exporters/src/csv.rs | 139 +++++++++++++----- crates/bitwarden-exporters/src/json.rs | 156 ++++++++++++++++++++- crates/bitwarden-exporters/src/lib.rs | 33 ++++- crates/bitwarden/src/tool/exporters/mod.rs | 11 +- 6 files changed, 300 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43580e43d..1cd35c375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,6 +459,7 @@ dependencies = [ "csv", "serde", "serde_json", + "thiserror", "uuid", ] diff --git a/crates/bitwarden-exporters/Cargo.toml b/crates/bitwarden-exporters/Cargo.toml index aa2a90b82..b8f982ccc 100644 --- a/crates/bitwarden-exporters/Cargo.toml +++ b/crates/bitwarden-exporters/Cargo.toml @@ -21,4 +21,5 @@ chrono = { version = ">=0.4.26, <0.5", features = [ csv = "1.3.0" serde = { version = ">=1.0, <2.0", features = ["derive"] } serde_json = ">=1.0.96, <2.0" +thiserror = ">=1.0.40, <2.0" uuid = { version = ">=1.3.3, <2.0", features = ["serde"] } diff --git a/crates/bitwarden-exporters/src/csv.rs b/crates/bitwarden-exporters/src/csv.rs index 82dad5d90..1774219bf 100644 --- a/crates/bitwarden-exporters/src/csv.rs +++ b/crates/bitwarden-exporters/src/csv.rs @@ -2,48 +2,56 @@ use std::collections::HashMap; use csv::Writer; use serde::Serializer; +use thiserror::Error; use uuid::Uuid; use crate::{Cipher, CipherType, Field, Folder}; -pub(crate) fn export_csv(folders: Vec, ciphers: Vec) -> Result { +#[derive(Debug, Error)] +pub enum CsvError { + #[error("CSV error")] + Csv, +} + +pub(crate) fn export_csv(folders: Vec, ciphers: Vec) -> Result { let folders: HashMap = folders.iter().map(|f| (f.id, f.name.clone())).collect(); - let rows = ciphers.iter().map(|c| { - let login = if let CipherType::Login(l) = &c.r#type { - Some(l) - } else { - None - }; - - CsvRow { - folder: c - .folder_id - .and_then(|f| folders.get(&f)) - .map(|f| f.to_owned()), - favorite: c.favorite, - r#type: c.r#type.to_string(), - name: c.name.to_owned(), - notes: c.notes.to_owned(), - fields: c.fields.clone(), - reprompt: c.reprompt, - login_uri: login.map(|l| l.login_uris.clone()).unwrap_or_default(), - login_username: login.map(|l| l.username.clone()), - login_password: login.map(|l| l.password.clone()), - login_totp: login.and_then(|l| l.totp.clone()), - } - }); + let rows = ciphers + .iter() + .filter(|c| matches!(c.r#type, CipherType::Login(_) | CipherType::SecureNote(_))) + .map(|c| { + let login = if let CipherType::Login(l) = &c.r#type { + Some(l) + } else { + None + }; + + CsvRow { + folder: c + .folder_id + .and_then(|f| folders.get(&f)) + .map(|f| f.to_owned()), + favorite: c.favorite, + r#type: c.r#type.to_string(), + name: c.name.to_owned(), + notes: c.notes.to_owned(), + fields: c.fields.clone(), + reprompt: c.reprompt, + login_uri: login + .map(|l| l.login_uris.iter().flat_map(|l| l.uri.clone()).collect()) + .unwrap_or_default(), + login_username: login.map(|l| l.username.clone()), + login_password: login.map(|l| l.password.clone()), + login_totp: login.and_then(|l| l.totp.clone()), + } + }); let mut wtr = Writer::from_writer(vec![]); for row in rows { wtr.serialize(row).unwrap(); } - String::from_utf8( - wtr.into_inner() - .map_err(|_| "Failed to write CSV".to_string())?, - ) - .map_err(|_| "Failed to convert CSV to UTF-8".to_string()) + String::from_utf8(wtr.into_inner().map_err(|_| CsvError::Csv)?).map_err(|_| CsvError::Csv) } /// CSV export format. See https://bitwarden.com/help/condition-bitwarden-import/#condition-a-csv @@ -103,7 +111,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::CipherLogin; + use crate::{Card, Login, LoginUri}; #[test] fn test_export_csv() { @@ -123,10 +131,13 @@ mod tests { folder_id: None, name: "test@bitwarden.com".to_string(), notes: None, - r#type: CipherType::Login(CipherLogin { + r#type: CipherType::Login(Login { username: "test@bitwarden.com".to_string(), password: "Abc123".to_string(), - login_uris: vec!["https://google.com".to_string()], + login_uris: vec![LoginUri { + uri: Some("https://google.com".to_string()), + r#match: None, + }], totp: None, }), favorite: false, @@ -141,10 +152,13 @@ mod tests { folder_id: Some("583e7665-0126-4d37-9139-b0d20184dd86".parse().unwrap()), name: "Steam Account".to_string(), notes: None, - r#type: CipherType::Login(CipherLogin { + r#type: CipherType::Login(Login { username: "steam".to_string(), password: "3Pvb8u7EfbV*nJ".to_string(), - login_uris: vec!["https://steampowered.com".to_string()], + login_uris: vec![LoginUri { + uri: Some("https://steampowered.com".to_string()), + r#match: None, + }], totp: Some("steam://ABCD123".to_string()), }), favorite: true, @@ -153,10 +167,14 @@ mod tests { Field { name: Some("Test".to_string()), value: Some("v".to_string()), + r#type: 0, + linked_id: None, }, Field { name: Some("Hidden".to_string()), value: Some("asdfer".to_string()), + r#type: 1, + linked_id: None, }, ], revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), @@ -175,4 +193,55 @@ mod tests { assert_eq!(csv, expected); } + + #[test] + fn test_export_ignore_card() { + let folders = vec![]; + let ciphers = vec![Cipher { + id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(), + folder_id: None, + name: "My Card".to_string(), + notes: None, + r#type: CipherType::Card(Card { + cardholder_name: None, + exp_month: None, + exp_year: None, + code: None, + brand: None, + number: None, + }), + favorite: false, + reprompt: 0, + fields: vec![], + revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + deleted_date: None, + }]; + + let csv = export_csv(folders, ciphers).unwrap(); + + assert_eq!(csv, ""); + } + + #[test] + fn test_export_ignore_identity() { + let folders = vec![]; + let ciphers = vec![Cipher { + id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(), + folder_id: None, + name: "My Identity".to_string(), + notes: None, + r#type: CipherType::Identity(), + favorite: false, + reprompt: 0, + fields: vec![], + revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(), + deleted_date: None, + }]; + + let csv = export_csv(folders, ciphers).unwrap(); + + assert_eq!(csv, ""); + } } diff --git a/crates/bitwarden-exporters/src/json.rs b/crates/bitwarden-exporters/src/json.rs index 5eeb273e0..53026a765 100644 --- a/crates/bitwarden-exporters/src/json.rs +++ b/crates/bitwarden-exporters/src/json.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; -use crate::{Cipher, Folder}; +use crate::{Cipher, Field, Folder}; pub(crate) fn export_json(folders: Vec, ciphers: Vec) -> Result { Ok("".to_owned()) @@ -47,6 +47,7 @@ struct JsonCipher { favorite: bool, reprompt: u8, + fields: Vec, password_history: Option>, revision_date: DateTime, @@ -60,11 +61,32 @@ struct JsonSecureNote { r#type: u8, } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonField { + name: Option, + value: Option, + r#type: u8, + linked_id: Option, +} + +impl From for JsonField { + fn from(field: Field) -> Self { + JsonField { + name: field.name, + value: field.value, + r#type: field.r#type, + linked_id: field.linked_id, + } + } +} + impl From for JsonCipher { fn from(cipher: Cipher) -> Self { let r#type = match cipher.r#type { crate::CipherType::Login(_) => 1, crate::CipherType::SecureNote(_) => 2, + crate::CipherType::Card(_) => 3, crate::CipherType::Identity() => 4, }; @@ -89,6 +111,7 @@ impl From for JsonCipher { secure_note, favorite: cipher.favorite, reprompt: cipher.reprompt, + fields: cipher.fields.iter().map(|f| f.clone().into()).collect(), password_history: None, revision_date: cipher.revision_date, creation_date: cipher.creation_date, @@ -99,10 +122,139 @@ impl From for JsonCipher { #[cfg(test)] mod tests { - use crate::Cipher; + use crate::{Cipher, Field, LoginUri}; use super::*; + #[test] + fn test_convert_login() { + let cipher = Cipher { + id: "25c8c414-b446-48e9-a1bd-b10700bbd740".parse().unwrap(), + folder_id: Some("942e2984-1b9a-453b-b039-b107012713b9".parse().unwrap()), + + name: "Bitwarden".to_string(), + notes: Some("My note".to_string()), + + r#type: crate::CipherType::Login(crate::Login { + username: "test@bitwarden.com".to_string(), + password: "asdfasdfasdf".to_string(), + login_uris: vec![LoginUri { + uri: Some("https://vault.bitwarden.com".to_string()), + r#match: None, + }], + totp: Some("ABC".to_string()), + }), + + favorite: true, + reprompt: 0, + + fields: vec![ + Field { + name: Some("Text".to_string()), + value: Some("A".to_string()), + r#type: 0, + linked_id: None, + }, + Field { + name: Some("Hidden".to_string()), + value: Some("B".to_string()), + r#type: 1, + linked_id: None, + }, + Field { + name: Some("Boolean (true)".to_string()), + value: Some("true".to_string()), + r#type: 2, + linked_id: None, + }, + Field { + name: Some("Boolean (false)".to_string()), + value: Some("false".to_string()), + r#type: 2, + linked_id: None, + }, + Field { + name: Some("Linked".to_string()), + value: None, + r#type: 3, + linked_id: Some(101), + }, + ], + + revision_date: "2024-01-30T14:09:33.753Z".parse().unwrap(), + creation_date: "2024-01-30T11:23:54.416Z".parse().unwrap(), + deleted_date: None, + }; + + // Convert to JsonCipher + let json = serde_json::to_string(&JsonCipher::from(cipher)).unwrap(); + + let expected = r#"{ + "passwordHistory": null, + "revisionDate": "2024-01-30T14:09:33.753Z", + "creationDate": "2024-01-30T11:23:54.416Z", + "deletedDate": null, + "id": "25c8c414-b446-48e9-a1bd-b10700bbd740", + "organizationId": null, + "folderId": "942e2984-1b9a-453b-b039-b107012713b9", + "type": 1, + "reprompt": 0, + "name": "Bitwarden", + "notes": "My note", + "favorite": true, + "fields": [ + { + "name": "Text", + "value": "A", + "type": 0, + "linkedId": null + }, + { + "name": "Hidden", + "value": "B", + "type": 1, + "linkedId": null + }, + { + "name": "Boolean (true)", + "value": "true", + "type": 2, + "linkedId": null + }, + { + "name": "Boolean (false)", + "value": "false", + "type": 2, + "linkedId": null + }, + { + "name": "Linked", + "value": null, + "type": 3, + "linkedId": 101 + } + ], + "login": { + "fido2Credentials": [], + "uris": [ + { + "match": null, + "uri": "https://vault.bitwarden.com" + } + ], + "username": "test@bitwarden.com", + "password": "asdfasdfasdf", + "totp": "ABC" + }, + "collectionIds": null + }"#; + + assert_eq!( + json.parse::().unwrap(), + expected.parse::().unwrap() + ) + } + #[test] fn test_convert_secure_note() { let cipher = Cipher { diff --git a/crates/bitwarden-exporters/src/lib.rs b/crates/bitwarden-exporters/src/lib.rs index 3ccd6df67..65f38dbd1 100644 --- a/crates/bitwarden-exporters/src/lib.rs +++ b/crates/bitwarden-exporters/src/lib.rs @@ -40,11 +40,14 @@ pub struct Cipher { pub struct Field { name: Option, value: Option, + r#type: u8, + linked_id: Option, } pub enum CipherType { - Login(CipherLogin), + Login(Login), Identity(), + Card(Card), SecureNote(SecureNote), } @@ -53,18 +56,42 @@ impl ToString for CipherType { match self { CipherType::Login(_) => "login".to_string(), CipherType::Identity() => "identity".to_string(), + CipherType::Card(_) => "card".to_string(), CipherType::SecureNote(_) => "note".to_string(), } } } -pub struct CipherLogin { +pub struct Login { pub username: String, pub password: String, - pub login_uris: Vec, + pub login_uris: Vec, pub totp: Option, } +pub struct LoginUri { + pub uri: Option, + pub r#match: Option, +} + +pub enum UriMatchType { + Domain = 0, + Host = 1, + StartsWith = 2, + Exact = 3, + RegularExpression = 4, + Never = 5, +} + +pub struct Card { + pub cardholder_name: Option, + pub exp_month: Option, + pub exp_year: Option, + pub code: Option, + pub brand: Option, + pub number: Option, +} + pub struct SecureNote { pub r#type: SecureNoteType, } diff --git a/crates/bitwarden/src/tool/exporters/mod.rs b/crates/bitwarden/src/tool/exporters/mod.rs index 22cf7152d..2e07cd382 100644 --- a/crates/bitwarden/src/tool/exporters/mod.rs +++ b/crates/bitwarden/src/tool/exporters/mod.rs @@ -53,7 +53,6 @@ impl TryFrom for bitwarden_exporters::Folder { Ok(Self { id: value.id.ok_or(Error::MissingFields)?, name: value.name, - revision_date: value.revision_date, }) } } @@ -64,6 +63,16 @@ impl TryFrom for bitwarden_exporters::Cipher { fn try_from(value: CipherView) -> Result { Ok(Self { id: value.id.ok_or(Error::MissingFields)?, + folder_id: todo!(), + name: todo!(), + notes: todo!(), + r#type: todo!(), + favorite: todo!(), + reprompt: todo!(), + fields: todo!(), + revision_date: todo!(), + creation_date: todo!(), + deleted_date: todo!(), }) } }