Skip to main content

bitwarden_exporters/
csv.rs

1use std::collections::HashMap;
2
3use csv::Writer;
4use serde::Serializer;
5use thiserror::Error;
6use uuid::Uuid;
7
8use crate::{Cipher, CipherType, Field, Folder};
9
10#[derive(Debug, Error)]
11pub enum CsvError {
12    #[error("CSV error")]
13    Csv,
14}
15
16pub(crate) fn export_csv(folders: Vec<Folder>, ciphers: Vec<Cipher>) -> Result<String, CsvError> {
17    let folders: HashMap<Uuid, String> = folders.into_iter().map(|f| (f.id, f.name)).collect();
18
19    let rows = ciphers
20        .into_iter()
21        .filter(|c| matches!(c.r#type, CipherType::Login(_) | CipherType::SecureNote(_)))
22        .map(|c| {
23            let login = if let CipherType::Login(l) = &c.r#type {
24                Some(l)
25            } else {
26                None
27            };
28
29            CsvRow {
30                folder: c
31                    .folder_id
32                    .and_then(|f| folders.get(&f))
33                    .map(|f| f.to_owned()),
34                favorite: c.favorite,
35                r#type: c.r#type.to_string(),
36                name: c.name.to_owned(),
37                notes: c.notes.to_owned(),
38                fields: c.fields,
39                reprompt: c.reprompt,
40                login_uri: login
41                    .map(|l| l.login_uris.iter().flat_map(|l| l.uri.clone()).collect())
42                    .unwrap_or_default(),
43                login_username: login.and_then(|l| l.username.clone()),
44                login_password: login.and_then(|l| l.password.clone()),
45                login_totp: login.and_then(|l| l.totp.clone()),
46            }
47        });
48
49    let mut wtr = Writer::from_writer(vec![]);
50    for row in rows {
51        wtr.serialize(row).expect("Serialize should be infallible");
52    }
53
54    String::from_utf8(wtr.into_inner().map_err(|_| CsvError::Csv)?).map_err(|_| CsvError::Csv)
55}
56
57/// CSV export format. See <https://bitwarden.com/help/condition-bitwarden-import/#condition-a-csv>
58///
59/// Be careful when changing this struct to maintain compatibility with old exports.
60#[derive(serde::Serialize)]
61struct CsvRow {
62    folder: Option<String>,
63    #[serde(serialize_with = "bool_serialize")]
64    favorite: bool,
65    r#type: String,
66    name: String,
67    notes: Option<String>,
68    #[serde(serialize_with = "fields_serialize")]
69    fields: Vec<Field>,
70    reprompt: u8,
71    #[serde(serialize_with = "vec_serialize")]
72    login_uri: Vec<String>,
73    login_username: Option<String>,
74    login_password: Option<String>,
75    login_totp: Option<String>,
76}
77
78fn vec_serialize<S>(x: &[String], s: S) -> Result<S::Ok, S::Error>
79where
80    S: Serializer,
81{
82    s.serialize_str(x.join(",").as_str())
83}
84
85fn bool_serialize<S>(x: &bool, s: S) -> Result<S::Ok, S::Error>
86where
87    S: Serializer,
88{
89    s.serialize_str(if *x { "1" } else { "" })
90}
91
92fn fields_serialize<S>(x: &[Field], s: S) -> Result<S::Ok, S::Error>
93where
94    S: Serializer,
95{
96    s.serialize_str(
97        x.iter()
98            .map(|f| {
99                format!(
100                    "{}: {}",
101                    f.name.to_owned().unwrap_or_default(),
102                    f.value.to_owned().unwrap_or_default()
103                )
104            })
105            .collect::<Vec<String>>()
106            .join("\n")
107            .as_str(),
108    )
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::{Card, Identity, Login, LoginUri};
115
116    #[test]
117    fn test_export_csv() {
118        let folders = vec![
119            Folder {
120                id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(),
121                name: "Test Folder A".to_string(),
122            },
123            Folder {
124                id: "583e7665-0126-4d37-9139-b0d20184dd86".parse().unwrap(),
125                name: "Test Folder B".to_string(),
126            },
127        ];
128        let ciphers = vec![
129            Cipher {
130                id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(),
131                folder_id: None,
132                name: "[email protected]".to_string(),
133                notes: None,
134                r#type: CipherType::Login(Box::new(Login {
135                    username: Some("[email protected]".to_string()),
136                    password: Some("Abc123".to_string()),
137                    login_uris: vec![LoginUri {
138                        uri: Some("https://google.com".to_string()),
139                        r#match: None,
140                    }],
141                    totp: None,
142                    fido2_credentials: None,
143                })),
144                favorite: false,
145                reprompt: 0,
146                fields: vec![],
147                password_history: None,
148                revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
149                creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
150                deleted_date: None,
151            },
152            Cipher {
153                id: "7dd81bd0-cc72-4f42-96e7-b0fc014e71a3".parse().unwrap(),
154                folder_id: Some("583e7665-0126-4d37-9139-b0d20184dd86".parse().unwrap()),
155                name: "Steam Account".to_string(),
156                notes: None,
157                r#type: CipherType::Login(Box::new(Login {
158                    username: Some("steam".to_string()),
159                    password: Some("3Pvb8u7EfbV*nJ".to_string()),
160                    login_uris: vec![LoginUri {
161                        uri: Some("https://steampowered.com".to_string()),
162                        r#match: None,
163                    }],
164                    totp: Some("steam://ABCD123".to_string()),
165                    fido2_credentials: None,
166                })),
167                favorite: true,
168                reprompt: 0,
169                fields: vec![
170                    Field {
171                        name: Some("Test".to_string()),
172                        value: Some("v".to_string()),
173                        r#type: 0,
174                        linked_id: None,
175                    },
176                    Field {
177                        name: Some("Hidden".to_string()),
178                        value: Some("asdfer".to_string()),
179                        r#type: 1,
180                        linked_id: None,
181                    },
182                ],
183                password_history: None,
184                revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
185                creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
186                deleted_date: None,
187            },
188        ];
189
190        let csv = export_csv(folders, ciphers).unwrap();
191        let expected = [
192            "folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp",
193            ",,login,[email protected],,,0,https://google.com,[email protected],Abc123,",
194            "Test Folder B,1,login,Steam Account,,\"Test: v\nHidden: asdfer\",0,https://steampowered.com,steam,3Pvb8u7EfbV*nJ,steam://ABCD123",
195            "",
196        ].join("\n");
197
198        assert_eq!(csv, expected);
199    }
200
201    #[test]
202    fn test_export_ignore_card() {
203        let folders = vec![];
204        let ciphers = vec![Cipher {
205            id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(),
206            folder_id: None,
207            name: "My Card".to_string(),
208            notes: None,
209            r#type: CipherType::Card(Box::new(Card {
210                cardholder_name: None,
211                exp_month: None,
212                exp_year: None,
213                code: None,
214                brand: None,
215                number: None,
216            })),
217            favorite: false,
218            reprompt: 0,
219            fields: vec![],
220            password_history: None,
221            revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
222            creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
223            deleted_date: None,
224        }];
225
226        let csv = export_csv(folders, ciphers).unwrap();
227
228        assert_eq!(csv, "");
229    }
230
231    #[test]
232    fn test_export_ignore_identity() {
233        let folders = vec![];
234        let ciphers = vec![Cipher {
235            id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(),
236            folder_id: None,
237            name: "My Identity".to_string(),
238            notes: None,
239            r#type: CipherType::Identity(Box::new(Identity {
240                title: None,
241                first_name: None,
242                middle_name: None,
243                last_name: None,
244                address1: None,
245                address2: None,
246                address3: None,
247                city: None,
248                state: None,
249                postal_code: None,
250                country: None,
251                company: None,
252                email: None,
253                phone: None,
254                ssn: None,
255                username: None,
256                passport_number: None,
257                license_number: None,
258            })),
259            favorite: false,
260            reprompt: 0,
261            fields: vec![],
262            password_history: None,
263            revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
264            creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
265            deleted_date: None,
266        }];
267
268        let csv = export_csv(folders, ciphers).unwrap();
269
270        assert_eq!(csv, "");
271    }
272}