sos_migrate/import/csv/
dashlane.rs

1//! Parser for the Dashlane CSV zip export.
2
3use async_trait::async_trait;
4use serde::Deserialize;
5use sos_core::{crypto::AccessKey, UtcDateTime};
6use sos_vault::{secret::IdentityKind, Vault};
7use sos_vfs as vfs;
8use std::{
9    collections::HashSet,
10    io::Cursor,
11    path::{Path, PathBuf},
12};
13use time::{Date, Month};
14use url::Url;
15use vcard4::{property::DeliveryAddress, Uri, VcardBuilder};
16
17use async_zip::tokio::read::seek::ZipFileReader;
18use tokio::io::{AsyncBufRead, AsyncSeek, BufReader};
19
20use super::{
21    GenericContactRecord, GenericCsvConvert, GenericCsvEntry,
22    GenericIdRecord, GenericNoteRecord, GenericPasswordRecord,
23    GenericPaymentRecord, UNTITLED,
24};
25use crate::{import::read_csv_records, Convert, Result};
26
27/// Record used to deserialize dashlane CSV files.
28#[derive(Debug)]
29pub enum DashlaneRecord {
30    /// Password login.
31    Password(DashlanePasswordRecord),
32    /// Secure note.
33    Note(DashlaneNoteRecord),
34    /// Identity record.
35    Id(DashlaneIdRecord),
36    /// Payment record.
37    Payment(DashlanePaymentRecord),
38    /// Contact record.
39    Contact(DashlaneContactRecord),
40}
41
42impl From<DashlaneRecord> for GenericCsvEntry {
43    fn from(value: DashlaneRecord) -> Self {
44        match value {
45            DashlaneRecord::Password(record) => {
46                GenericCsvEntry::Password(record.into())
47            }
48            DashlaneRecord::Note(record) => {
49                GenericCsvEntry::Note(record.into())
50            }
51            DashlaneRecord::Id(record) => GenericCsvEntry::Id(record.into()),
52            DashlaneRecord::Payment(record) => {
53                GenericCsvEntry::Payment(record.into())
54            }
55            DashlaneRecord::Contact(record) => {
56                GenericCsvEntry::Contact(Box::new(record.into()))
57            }
58        }
59    }
60}
61
62/// Record for an entry in a Dashlane notes CSV export.
63#[derive(Debug, Deserialize)]
64pub struct DashlaneNoteRecord {
65    /// The title of the entry.
66    pub title: String,
67    /// The note for the entry.
68    pub note: String,
69}
70
71impl From<DashlaneNoteRecord> for DashlaneRecord {
72    fn from(value: DashlaneNoteRecord) -> Self {
73        Self::Note(value)
74    }
75}
76
77impl From<DashlaneNoteRecord> for GenericNoteRecord {
78    fn from(value: DashlaneNoteRecord) -> Self {
79        let label = if value.title.is_empty() {
80            UNTITLED.to_owned()
81        } else {
82            value.title
83        };
84        Self {
85            label,
86            text: value.note,
87            tags: None,
88            note: None,
89        }
90    }
91}
92
93/// Record for an entry in a Dashlane id CSV export.
94#[derive(Debug, Deserialize)]
95pub struct DashlaneIdRecord {
96    /// The type of the entry.
97    #[serde(rename = "type")]
98    pub kind: String,
99    /// The number for the entry.
100    pub number: String,
101    /// The name for the entry.
102    pub name: String,
103    /// The issue date for the entry.
104    pub issue_date: String,
105    /// The expiration date for the entry.
106    pub expiration_date: String,
107    /// The place of issue for the entry.
108    pub place_of_issue: String,
109    /// The state for the entry.
110    pub state: String,
111}
112
113impl From<DashlaneIdRecord> for DashlaneRecord {
114    fn from(value: DashlaneIdRecord) -> Self {
115        Self::Id(value)
116    }
117}
118
119impl From<DashlaneIdRecord> for GenericIdRecord {
120    fn from(value: DashlaneIdRecord) -> Self {
121        let label = if value.name.is_empty() {
122            UNTITLED.to_owned()
123        } else {
124            value.name
125        };
126
127        let id_kind = match &value.kind[..] {
128            "card" => IdentityKind::IdCard,
129            "passport" => IdentityKind::Passport,
130            "license" => IdentityKind::DriverLicense,
131            "social_security" => IdentityKind::SocialSecurity,
132            "tax_number" => IdentityKind::TaxNumber,
133            _ => {
134                panic!("unsupported type of id {}", value.kind);
135            }
136        };
137
138        let issue_place =
139            if !value.state.is_empty() && !value.place_of_issue.is_empty() {
140                format!("{}, {}", value.state, value.place_of_issue)
141            } else {
142                value.place_of_issue
143            };
144
145        let issue_place = if !issue_place.is_empty() {
146            Some(issue_place)
147        } else {
148            None
149        };
150
151        let issue_date = if !value.issue_date.is_empty() {
152            match UtcDateTime::parse_simple_date(&value.issue_date) {
153                Ok(date) => Some(date),
154                Err(_) => None,
155            }
156        } else {
157            None
158        };
159
160        let expiration_date = if !value.expiration_date.is_empty() {
161            match UtcDateTime::parse_simple_date(&value.expiration_date) {
162                Ok(date) => Some(date),
163                Err(_) => None,
164            }
165        } else {
166            None
167        };
168
169        Self {
170            label,
171            id_kind,
172            number: value.number,
173            issue_place,
174            issue_date,
175            expiration_date,
176            tags: None,
177            note: None,
178        }
179    }
180}
181
182/// Record for an entry in a Dashlane id CSV export.
183#[derive(Debug, Deserialize)]
184pub struct DashlanePaymentRecord {
185    /// The type of the entry.
186    #[serde(rename = "type")]
187    pub kind: String,
188    /// The account name for the entry.
189    pub account_name: String,
190    /// The account holder for the entry.
191    pub account_holder: String,
192    /// The account number for the entry.
193    pub account_number: String,
194    /// The routing number for the entry.
195    pub routing_number: String,
196    /// The CC number for the entry.
197    pub cc_number: String,
198    /// The CVV code for the entry.
199    pub code: String,
200    /// The expiration month for the entry.
201    pub expiration_month: String,
202    /// The expiration year for the entry.
203    pub expiration_year: String,
204    /// The country for the entry.
205    pub country: String,
206    /// The note for the entry.
207    pub note: String,
208}
209
210impl From<DashlanePaymentRecord> for DashlaneRecord {
211    fn from(value: DashlanePaymentRecord) -> Self {
212        Self::Payment(value)
213    }
214}
215
216impl From<DashlanePaymentRecord> for GenericPaymentRecord {
217    fn from(value: DashlanePaymentRecord) -> Self {
218        let label = if value.account_name.is_empty() {
219            UNTITLED.to_owned()
220        } else {
221            value.account_name
222        };
223
224        let expiration = if let (Ok(month), Ok(year)) = (
225            value.expiration_month.parse::<u8>(),
226            value.expiration_year.parse::<i32>(),
227        ) {
228            if let Ok(month) = Month::try_from(month) {
229                UtcDateTime::from_calendar_date(year, month, 1).ok()
230            } else {
231                None
232            }
233        } else {
234            None
235        };
236
237        let note = if !value.note.is_empty() {
238            Some(value.note)
239        } else {
240            None
241        };
242
243        match &value.kind[..] {
244            "bank" => GenericPaymentRecord::BankAccount {
245                label,
246                account_holder: value.account_holder,
247                account_number: value.account_number,
248                routing_number: value.routing_number,
249                country: value.country,
250                note,
251                tags: None,
252            },
253            "payment_card" => GenericPaymentRecord::Card {
254                label,
255                number: value.cc_number,
256                code: value.code,
257                expiration,
258                country: value.country,
259                note,
260                tags: None,
261            },
262            _ => panic!("unexpected payment type {}", value.kind),
263        }
264    }
265}
266
267/// Record for an entry in a Dashlane passwords CSV export.
268#[derive(Debug, Deserialize)]
269pub struct DashlanePasswordRecord {
270    /// The title of the entry.
271    pub title: String,
272    /// The URL of the entry.
273    pub url: Option<Url>,
274    /// The username for the entry.
275    pub username: String,
276    /// The password for the entry.
277    pub password: String,
278    /// The note for the entry.
279    pub note: String,
280    /// The category for the entry.
281    pub category: String,
282    /// The OTP secret for the entry.
283    #[serde(rename = "otpSecret")]
284    pub otp_secret: String,
285}
286
287impl From<DashlanePasswordRecord> for DashlaneRecord {
288    fn from(value: DashlanePasswordRecord) -> Self {
289        Self::Password(value)
290    }
291}
292
293impl From<DashlanePasswordRecord> for GenericPasswordRecord {
294    fn from(value: DashlanePasswordRecord) -> Self {
295        let label = if value.title.is_empty() {
296            UNTITLED.to_owned()
297        } else {
298            value.title
299        };
300
301        let tags = if !value.category.is_empty() {
302            let mut tags = HashSet::new();
303            tags.insert(value.category);
304            Some(tags)
305        } else {
306            None
307        };
308
309        let note = if !value.note.is_empty() {
310            Some(value.note)
311        } else {
312            None
313        };
314
315        let url = if let Some(url) = value.url {
316            vec![url]
317        } else {
318            vec![]
319        };
320
321        Self {
322            label,
323            url,
324            username: value.username,
325            password: value.password,
326            otp_auth: None,
327            tags,
328            note,
329        }
330    }
331}
332
333/// Record for an entry in a Dashlane personalInfo CSV export.
334///
335/// Fields that are currently not handled:
336///
337/// * login
338/// * place_of_birth
339/// * email_type
340/// * address_door_code
341///
342#[derive(Debug, Deserialize)]
343pub struct DashlaneContactRecord {
344    /// The item name of the entry.
345    pub item_name: String,
346    /// The title.
347    pub title: String,
348    /// The first name.
349    pub first_name: String,
350    /// The middle name.
351    pub middle_name: String,
352    /// The last name.
353    pub last_name: String,
354
355    /// The address.
356    pub address: String,
357    /// The city.
358    pub city: String,
359    /// The state.
360    pub state: String,
361    /// The country.
362    pub country: String,
363    /// The postal code.
364    pub zip: String,
365
366    /// Address recipient.
367    pub address_recipient: String,
368    /// Address apartment.
369    pub address_apartment: String,
370    /// Address floor.
371    pub address_floor: String,
372    /// Address building.
373    pub address_building: String,
374
375    /// The phone number.
376    pub phone_number: String,
377    /// An email address.
378    pub email: String,
379    /// A website URL.
380    pub url: String,
381
382    /// A date of birth.
383    pub date_of_birth: String,
384
385    /// A job title.
386    pub job_title: String,
387}
388
389impl From<DashlaneContactRecord> for DashlaneRecord {
390    fn from(value: DashlaneContactRecord) -> Self {
391        Self::Contact(value)
392    }
393}
394
395impl From<DashlaneContactRecord> for GenericContactRecord {
396    fn from(value: DashlaneContactRecord) -> Self {
397        let has_some_name_parts = !value.last_name.is_empty()
398            || !value.first_name.is_empty()
399            || !value.middle_name.is_empty()
400            || !value.title.is_empty();
401
402        let name: [String; 5] = [
403            value.last_name.clone(),
404            value.first_name.clone(),
405            value.middle_name.clone(),
406            value.title.clone(),
407            String::new(),
408        ];
409
410        let formatted_name = if has_some_name_parts {
411            let mut parts: Vec<String> = Vec::new();
412            if !value.title.is_empty() {
413                parts.push(value.title);
414            }
415            if !value.first_name.is_empty() {
416                parts.push(value.first_name);
417            }
418            if !value.middle_name.is_empty() {
419                parts.push(value.middle_name);
420            }
421            if !value.last_name.is_empty() {
422                parts.push(value.last_name);
423            }
424            parts.join(" ")
425        } else if !value.item_name.is_empty() {
426            value.item_name.clone()
427        } else {
428            UNTITLED.to_owned()
429        };
430
431        let label = if value.item_name.is_empty() {
432            formatted_name.clone()
433        } else if !value.item_name.is_empty() {
434            value.item_name
435        } else {
436            UNTITLED.to_owned()
437        };
438
439        let date_of_birth: Option<Date> = if !value.date_of_birth.is_empty() {
440            if let Ok(date_time) =
441                UtcDateTime::parse_simple_date(&value.date_of_birth)
442            {
443                Some(date_time.into_date())
444            } else {
445                None
446            }
447        } else {
448            None
449        };
450
451        let url: Option<Uri> = if !value.url.is_empty() {
452            value.url.parse().ok()
453        } else {
454            None
455        };
456
457        let extended_address = vec![
458            value.address_recipient,
459            value.address_apartment,
460            value.address_floor,
461            value.address_building,
462        ];
463
464        let has_some_address_parts = !value.address.is_empty()
465            || !value.city.is_empty()
466            || !value.state.is_empty()
467            || !value.zip.is_empty()
468            || !value.country.is_empty();
469
470        let address = if has_some_address_parts {
471            Some(DeliveryAddress {
472                po_box: None,
473                extended_address: if !extended_address.is_empty() {
474                    Some(extended_address.join(","))
475                } else {
476                    None
477                },
478                street_address: if !value.address.is_empty() {
479                    Some(value.address)
480                } else {
481                    None
482                },
483                locality: if !value.city.is_empty() {
484                    Some(value.city)
485                } else {
486                    None
487                },
488                region: if !value.state.is_empty() {
489                    Some(value.state)
490                } else {
491                    None
492                },
493                country_name: if !value.country.is_empty() {
494                    Some(value.country)
495                } else {
496                    None
497                },
498                postal_code: if !value.zip.is_empty() {
499                    Some(value.zip)
500                } else {
501                    None
502                },
503            })
504        } else {
505            None
506        };
507
508        let mut builder = VcardBuilder::new(formatted_name);
509        if has_some_name_parts {
510            builder = builder.name(name);
511        }
512        if let Some(address) = address {
513            builder = builder.address(address);
514        }
515        if !value.phone_number.is_empty() {
516            builder = builder.telephone(value.phone_number);
517        }
518        if !value.email.is_empty() {
519            builder = builder.email(value.email);
520        }
521        if let Some(url) = url {
522            builder = builder.url(url);
523        }
524        if !value.job_title.is_empty() {
525            builder = builder.title(value.job_title);
526        }
527        if let Some(date) = date_of_birth {
528            builder = builder.birthday(date.into());
529        }
530        let vcard = builder.finish();
531        Self {
532            label,
533            vcard,
534            tags: None,
535            note: None,
536        }
537    }
538}
539
540/// Parse records from a path.
541pub async fn parse_path<P: AsRef<Path>>(
542    path: P,
543) -> Result<Vec<DashlaneRecord>> {
544    parse(BufReader::new(vfs::File::open(path.as_ref()).await?)).await
545}
546
547async fn read_entry<R: AsyncBufRead + AsyncSeek + Unpin>(
548    zip: &mut ZipFileReader<R>,
549    index: usize,
550) -> Result<Vec<u8>> {
551    let mut reader = zip.reader_with_entry(index).await?;
552    let mut buffer = Vec::new();
553    reader.read_to_end_checked(&mut buffer).await?;
554    Ok(buffer)
555}
556
557async fn parse<R: AsyncBufRead + AsyncSeek + Unpin>(
558    rdr: R,
559) -> Result<Vec<DashlaneRecord>> {
560    let mut records = Vec::new();
561    let mut zip = ZipFileReader::with_tokio(rdr).await?;
562
563    for index in 0..zip.file().entries().len() {
564        let entry = zip.file().entries().get(index).unwrap();
565        let file_name = entry.filename();
566        let file_name = file_name.as_str()?;
567
568        match file_name {
569            "securenotes.csv" => {
570                let mut buffer = read_entry(&mut zip, index).await?;
571                let reader = Cursor::new(&mut buffer);
572                let mut items: Vec<DashlaneRecord> =
573                    read_csv_records::<DashlaneNoteRecord, _>(reader)
574                        .await?
575                        .into_iter()
576                        .map(|r| r.into())
577                        .collect();
578                records.append(&mut items);
579            }
580            "credentials.csv" => {
581                let mut buffer = read_entry(&mut zip, index).await?;
582                let reader = Cursor::new(&mut buffer);
583                let mut items: Vec<DashlaneRecord> =
584                    read_csv_records::<DashlanePasswordRecord, _>(reader)
585                        .await?
586                        .into_iter()
587                        .map(|r| r.into())
588                        .collect();
589                records.append(&mut items);
590            }
591            "ids.csv" => {
592                let mut buffer = read_entry(&mut zip, index).await?;
593                let reader = Cursor::new(&mut buffer);
594                let mut items: Vec<DashlaneRecord> =
595                    read_csv_records::<DashlaneIdRecord, _>(reader)
596                        .await?
597                        .into_iter()
598                        .map(|r| r.into())
599                        .collect();
600                records.append(&mut items);
601            }
602            "payments.csv" => {
603                let mut buffer = read_entry(&mut zip, index).await?;
604                let reader = Cursor::new(&mut buffer);
605                let mut items: Vec<DashlaneRecord> =
606                    read_csv_records::<DashlanePaymentRecord, _>(reader)
607                        .await?
608                        .into_iter()
609                        .map(|r| r.into())
610                        .collect();
611                records.append(&mut items);
612            }
613            "personalInfo.csv" => {
614                let mut buffer = read_entry(&mut zip, index).await?;
615                let reader = Cursor::new(&mut buffer);
616                let mut items: Vec<DashlaneRecord> =
617                    read_csv_records::<DashlaneContactRecord, _>(reader)
618                        .await?
619                        .into_iter()
620                        .map(|r| r.into())
621                        .collect();
622                records.append(&mut items);
623            }
624            _ => {
625                eprintln!(
626                    "unsupported dashlane file encountered {}",
627                    file_name
628                );
629            }
630        }
631    }
632    Ok(records)
633}
634
635/// Import a Dashlane CSV zip archive into a vault.
636pub struct DashlaneCsvZip;
637
638#[async_trait]
639impl Convert for DashlaneCsvZip {
640    type Input = PathBuf;
641
642    async fn convert(
643        &self,
644        source: Self::Input,
645        vault: Vault,
646        key: &AccessKey,
647    ) -> crate::Result<Vault> {
648        let records: Vec<GenericCsvEntry> = parse_path(source)
649            .await?
650            .into_iter()
651            .map(|r| r.into())
652            .collect();
653        GenericCsvConvert.convert(records, vault, key).await
654    }
655}