sos_migrate/import/csv/
bitwarden.rs

1//! Parser for the Bitwarden CSV export.
2//!
3//! Unlike most of the other formats this format includes notes
4//! as well as passwords.
5
6use async_trait::async_trait;
7use serde::Deserialize;
8use sos_core::crypto::AccessKey;
9use sos_vault::Vault;
10use sos_vfs as vfs;
11use std::path::{Path, PathBuf};
12use tokio::io::AsyncRead;
13use url::Url;
14
15use super::{
16    GenericCsvConvert, GenericCsvEntry, GenericNoteRecord,
17    GenericPasswordRecord, UNTITLED,
18};
19use crate::{import::read_csv_records, Convert, Result};
20
21const TYPE_LOGIN: &str = "login";
22const TYPE_NOTE: &str = "note";
23
24/// Record for an entry in a Bitwarden passwords CSV export.
25#[derive(Deserialize)]
26pub struct BitwardenPasswordRecord {
27    /// A folder name for the entry.
28    pub folder: String,
29    /// A favorite flag for the entry.
30    pub favorite: String,
31    /// The type of the entry.
32    #[serde(rename = "type")]
33    pub kind: String,
34    /// The name of the entry.
35    pub name: String,
36    /// The notes for the entry.
37    pub notes: String,
38    /// The fields for the entry.
39    pub fields: String,
40    /// The reprompt for the entry.
41    pub reprompt: String,
42    /// The URL of the entry.
43    pub login_uri: Option<Url>,
44    /// The username for the entry.
45    pub login_username: String,
46    /// The password for the entry.
47    pub login_password: String,
48    /// The login TOTP for the entry.
49    pub login_totp: String,
50}
51
52impl From<BitwardenPasswordRecord> for GenericPasswordRecord {
53    fn from(value: BitwardenPasswordRecord) -> Self {
54        let label = if value.name.is_empty() {
55            UNTITLED.to_owned()
56        } else {
57            value.name
58        };
59
60        let note = if !value.notes.is_empty() {
61            Some(value.notes)
62        } else {
63            None
64        };
65
66        let url = if let Some(uri) = value.login_uri {
67            vec![uri]
68        } else {
69            vec![]
70        };
71
72        Self {
73            label,
74            url,
75            username: value.login_username,
76            password: value.login_password,
77            otp_auth: None,
78            tags: None,
79            note,
80        }
81    }
82}
83
84impl From<BitwardenPasswordRecord> for GenericNoteRecord {
85    fn from(value: BitwardenPasswordRecord) -> Self {
86        let label = if value.name.is_empty() {
87            UNTITLED.to_owned()
88        } else {
89            value.name
90        };
91        Self {
92            label,
93            text: value.notes,
94            tags: None,
95            note: None,
96        }
97    }
98}
99
100impl From<BitwardenPasswordRecord> for GenericCsvEntry {
101    fn from(value: BitwardenPasswordRecord) -> Self {
102        if value.kind == TYPE_LOGIN {
103            Self::Password(value.into())
104        } else {
105            Self::Note(value.into())
106        }
107    }
108}
109
110/// Parse records from a reader.
111pub async fn parse_reader<R: AsyncRead + Unpin + Send>(
112    reader: R,
113) -> Result<Vec<BitwardenPasswordRecord>> {
114    read_csv_records::<BitwardenPasswordRecord, _>(reader).await
115}
116
117/// Parse records from a path.
118pub async fn parse_path<P: AsRef<Path>>(
119    path: P,
120) -> Result<Vec<BitwardenPasswordRecord>> {
121    parse_reader(vfs::File::open(path).await?).await
122}
123
124/// Import a Bitwarden passwords CSV export into a vault.
125pub struct BitwardenCsv;
126
127#[async_trait]
128impl Convert for BitwardenCsv {
129    type Input = PathBuf;
130
131    async fn convert(
132        &self,
133        source: Self::Input,
134        vault: Vault,
135        key: &AccessKey,
136    ) -> crate::Result<Vault> {
137        let records: Vec<GenericCsvEntry> = parse_path(source)
138            .await?
139            .into_iter()
140            .filter(|record| {
141                record.kind == TYPE_LOGIN || record.kind == TYPE_NOTE
142            })
143            .map(|r| r.into())
144            .collect();
145        GenericCsvConvert.convert(records, vault, key).await
146    }
147}