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#[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}