Skip to main content

bitwarden_importers/importers/onepassword/convert/
mod.rs

1//! Maps downloaded 1Password vaults onto the importer's [`ParsedImport`].
2//!
3//! Vaults become folders and every item keeps its title and note. Login and the categories built
4//! around a credential land on a login; Credit Card, Identity and SSH Key on their own types.
5//! Everything else becomes a secure note. Whatever a mapping does not claim survives as a custom
6//! field, with dates, month/year values and addresses rendered as text.
7//!
8//! This module drives the walk and picks each item's mapping; `login`, `credential`, `card`,
9//! `identity` and `ssh_key` hold the mappings, `field` the leftover pass that keeps whatever they
10//! did not claim, and `claimed` the bookkeeping that joins the two.
11
12mod card;
13mod claimed;
14mod credential;
15mod field;
16mod identity;
17mod login;
18mod ssh_key;
19#[cfg(test)]
20mod tests;
21mod value;
22
23use bitwarden_exporters::{CipherType, Field, ImportingCipher, SecureNote, SecureNoteType};
24use chrono::Utc;
25
26use self::{
27    card::card,
28    claimed::Claimed,
29    credential::{
30        API_CREDENTIAL_FIELDS, CredentialFields, DATABASE_FIELDS, EMAIL_ACCOUNT_FIELDS,
31        SERVER_FIELDS, WIRELESS_ROUTER_FIELDS, credential_login, password,
32    },
33    field::{TEXT_FIELD, fields_from_details},
34    identity::identity,
35    login::login,
36    ssh_key::ssh_key,
37    value::non_blank,
38};
39use crate::{
40    importers::onepassword::access::{
41        model::{Item, ItemCategory, Vault},
42        wire::VaultItemOverview,
43    },
44    pipeline::ParsedImport,
45};
46
47/// Stands in for a title 1Password left empty, matching the KDBX importer.
48const UNTITLED_ITEM: &str = "--";
49
50/// Stands in for a vault name 1Password left empty, matching the KDBX importer's group naming.
51const UNNAMED_VAULT: &str = "-";
52
53/// Names the custom field that keeps an item's tags. Bitwarden has nothing else to put them in.
54const TAGS_FIELD: &str = "tags";
55
56/// Added to a Document item's note. The item is the file, and an import moves vault data rather
57/// than files, so the note is all that is left to say so.
58const DOCUMENT_NOTE: &str = "This was a 1Password document. The attached file was not imported.";
59
60/// Converts every downloaded vault into ciphers, one folder per vault.
61pub fn convert(vaults: Vec<Vault>) -> ParsedImport {
62    let mut parsed = ParsedImport {
63        ciphers: Vec::new(),
64        folders: Vec::new(),
65        folder_relationships: Vec::new(),
66    };
67
68    for (folder_index, vault) in vaults.into_iter().enumerate() {
69        parsed
70            .folders
71            .push(non_blank(&vault.name).unwrap_or(UNNAMED_VAULT).to_string());
72
73        for item in vault.items {
74            let cipher_index = parsed.ciphers.len();
75            parsed.ciphers.push(convert_item(item));
76            parsed
77                .folder_relationships
78                .push((cipher_index, folder_index));
79        }
80    }
81
82    parsed
83}
84
85fn convert_item(item: Item) -> ImportingCipher {
86    // The import endpoint sets its own dates, so the ones 1Password sends are not worth carrying.
87    let now = Utc::now();
88    let (r#type, claimed) = cipher_type(&item);
89    let mut fields = fields_from_details(&item.details, &claimed);
90    fields.extend(tags_field(&item.overview));
91
92    ImportingCipher {
93        folder_id: None,
94        name: item
95            .overview
96            .title
97            .as_deref()
98            .and_then(non_blank)
99            .unwrap_or(UNTITLED_ITEM)
100            .to_string(),
101        notes: notes(&item),
102        r#type,
103        favorite: false,
104        reprompt: 0,
105        fields,
106        revision_date: now,
107        creation_date: now,
108        deleted_date: None,
109    }
110}
111
112/// The item's note, with a line added when the item was a document whose file could not come
113/// along.
114fn notes(item: &Item) -> Option<String> {
115    let note = item.details.note.as_deref().and_then(non_blank);
116
117    match (item.category == ItemCategory::Document, note) {
118        (true, Some(note)) => Some(format!("{note}\n\n{DOCUMENT_NOTE}")),
119        (true, None) => Some(DOCUMENT_NOTE.to_string()),
120        (false, note) => note.map(str::to_string),
121    }
122}
123
124/// Keeps an item's tags in a custom field. They are multi-valued and Bitwarden has no equivalent,
125/// so they arrive as one comma-separated field rather than being dropped.
126fn tags_field(overview: &VaultItemOverview) -> Option<Field> {
127    let tags: Vec<&str> = overview
128        .tags
129        .iter()
130        .flatten()
131        .filter_map(|tag| non_blank(tag))
132        .collect();
133
134    (!tags.is_empty()).then(|| Field {
135        name: Some(TAGS_FIELD.to_string()),
136        value: Some(tags.join(", ")),
137        r#type: TEXT_FIELD,
138        linked_id: None,
139    })
140}
141
142/// Picks the Bitwarden cipher type for an item's 1Password category, and reports which of the
143/// item's fields it read. A category without a mapping becomes a secure note, which loses nothing
144/// because every field then becomes a custom field.
145fn cipher_type(item: &Item) -> (CipherType, Claimed) {
146    let (overview, details) = (&item.overview, &item.details);
147    let credential = |fields: &CredentialFields| {
148        typed(
149            credential_login(overview, details, fields),
150            CipherType::Login,
151        )
152    };
153
154    match item.category {
155        ItemCategory::Login => typed(login(overview, details), CipherType::Login),
156        ItemCategory::Password => typed(password(overview, details), CipherType::Login),
157        ItemCategory::Server => credential(&SERVER_FIELDS),
158        ItemCategory::Database => credential(&DATABASE_FIELDS),
159        ItemCategory::ApiCredential => credential(&API_CREDENTIAL_FIELDS),
160        ItemCategory::EmailAccount => credential(&EMAIL_ACCOUNT_FIELDS),
161        ItemCategory::WirelessRouter => credential(&WIRELESS_ROUTER_FIELDS),
162        ItemCategory::CreditCard => typed(card(details), CipherType::Card),
163        ItemCategory::Identity => typed(identity(details), CipherType::Identity),
164        // A key the vault cannot use stays a note, with its material kept in the fields.
165        ItemCategory::SshKey => {
166            ssh_key(details).map_or_else(secure_note, |key| typed(key, CipherType::SshKey))
167        }
168        _ => secure_note(),
169    }
170}
171
172/// Wraps what a mapping read in the cipher type it belongs to.
173fn typed<T>(
174    (mapped, claimed): (T, Claimed),
175    cipher_type: fn(Box<T>) -> CipherType,
176) -> (CipherType, Claimed) {
177    (cipher_type(Box::new(mapped)), claimed)
178}
179
180fn secure_note() -> (CipherType, Claimed) {
181    (
182        CipherType::SecureNote(Box::new(SecureNote {
183            r#type: SecureNoteType::Generic,
184        })),
185        Claimed::default(),
186    )
187}