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                revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
148                creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
149                deleted_date: None,
150            },
151            Cipher {
152                id: "7dd81bd0-cc72-4f42-96e7-b0fc014e71a3".parse().unwrap(),
153                folder_id: Some("583e7665-0126-4d37-9139-b0d20184dd86".parse().unwrap()),
154                name: "Steam Account".to_string(),
155                notes: None,
156                r#type: CipherType::Login(Box::new(Login {
157                    username: Some("steam".to_string()),
158                    password: Some("3Pvb8u7EfbV*nJ".to_string()),
159                    login_uris: vec![LoginUri {
160                        uri: Some("https://steampowered.com".to_string()),
161                        r#match: None,
162                    }],
163                    totp: Some("steam://ABCD123".to_string()),
164                    fido2_credentials: None,
165                })),
166                favorite: true,
167                reprompt: 0,
168                fields: vec![
169                    Field {
170                        name: Some("Test".to_string()),
171                        value: Some("v".to_string()),
172                        r#type: 0,
173                        linked_id: None,
174                    },
175                    Field {
176                        name: Some("Hidden".to_string()),
177                        value: Some("asdfer".to_string()),
178                        r#type: 1,
179                        linked_id: None,
180                    },
181                ],
182                revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
183                creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
184                deleted_date: None,
185            },
186        ];
187
188        let csv = export_csv(folders, ciphers).unwrap();
189        let expected = [
190            "folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp",
191            ",,login,[email protected],,,0,https://google.com,[email protected],Abc123,",
192            "Test Folder B,1,login,Steam Account,,\"Test: v\nHidden: asdfer\",0,https://steampowered.com,steam,3Pvb8u7EfbV*nJ,steam://ABCD123",
193            "",
194        ].join("\n");
195
196        assert_eq!(csv, expected);
197    }
198
199    #[test]
200    fn test_export_ignore_card() {
201        let folders = vec![];
202        let ciphers = vec![Cipher {
203            id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(),
204            folder_id: None,
205            name: "My Card".to_string(),
206            notes: None,
207            r#type: CipherType::Card(Box::new(Card {
208                cardholder_name: None,
209                exp_month: None,
210                exp_year: None,
211                code: None,
212                brand: None,
213                number: None,
214            })),
215            favorite: false,
216            reprompt: 0,
217            fields: vec![],
218            revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
219            creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
220            deleted_date: None,
221        }];
222
223        let csv = export_csv(folders, ciphers).unwrap();
224
225        assert_eq!(csv, "");
226    }
227
228    #[test]
229    fn test_export_ignore_identity() {
230        let folders = vec![];
231        let ciphers = vec![Cipher {
232            id: "d55d65d7-c161-40a4-94ca-b0d20184d91a".parse().unwrap(),
233            folder_id: None,
234            name: "My Identity".to_string(),
235            notes: None,
236            r#type: CipherType::Identity(Box::new(Identity {
237                title: None,
238                first_name: None,
239                middle_name: None,
240                last_name: None,
241                address1: None,
242                address2: None,
243                address3: None,
244                city: None,
245                state: None,
246                postal_code: None,
247                country: None,
248                company: None,
249                email: None,
250                phone: None,
251                ssn: None,
252                username: None,
253                passport_number: None,
254                license_number: None,
255            })),
256            favorite: false,
257            reprompt: 0,
258            fields: vec![],
259            revision_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
260            creation_date: "2024-01-30T11:28:20.036Z".parse().unwrap(),
261            deleted_date: None,
262        }];
263
264        let csv = export_csv(folders, ciphers).unwrap();
265
266        assert_eq!(csv, "");
267    }
268}