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