Skip to content

Commit

Permalink
Wrap up csv export
Browse files Browse the repository at this point in the history
  • Loading branch information
Hinton committed Jan 31, 2024
1 parent 076aa9e commit ab4aaed
Show file tree
Hide file tree
Showing 6 changed files with 300 additions and 41 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/bitwarden-exporters/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
139 changes: 104 additions & 35 deletions crates/bitwarden-exporters/src/csv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Folder>, ciphers: Vec<Cipher>) -> Result<String, String> {
#[derive(Debug, Error)]
pub enum CsvError {
#[error("CSV error")]
Csv,
}

pub(crate) fn export_csv(folders: Vec<Folder>, ciphers: Vec<Cipher>) -> Result<String, CsvError> {
let folders: HashMap<Uuid, String> = 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
Expand Down Expand Up @@ -103,7 +111,7 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::CipherLogin;
use crate::{Card, Login, LoginUri};

#[test]
fn test_export_csv() {
Expand All @@ -123,10 +131,13 @@ mod tests {
folder_id: None,
name: "[email protected]".to_string(),
notes: None,
r#type: CipherType::Login(CipherLogin {
r#type: CipherType::Login(Login {
username: "[email protected]".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,
Expand All @@ -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,
Expand All @@ -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(),
Expand All @@ -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, "");
}
}
Loading

0 comments on commit ab4aaed

Please sign in to comment.