Skip to main content

bitwarden_importers/importers/onepassword/convert/
field.rs

1//! The leftover pass: every field a category mapping did not claim becomes a custom field, so no
2//! part of an item is lost.
3
4use bitwarden_exporters::Field;
5
6use super::{
7    claimed::{Claimed, section_fields},
8    value::{non_blank, render_value},
9};
10use crate::importers::onepassword::access::wire::{SshKeyAttributes, VaultItemDetails};
11
12/// Bitwarden's custom field types.
13pub(super) const TEXT_FIELD: u8 = 0;
14pub(super) const HIDDEN_FIELD: u8 = 1;
15
16/// Collects every field a category mapping did not claim, so no part of an item is lost.
17///
18/// Fields without a value are skipped: 1Password stores its whole category template on every item,
19/// so most of them are empty.
20pub(super) fn fields_from_details(details: &VaultItemDetails, claimed: &Claimed) -> Vec<Field> {
21    let mut fields = Vec::new();
22    // A mapping reads the first field carrying a designation, so the claim is spent on that one
23    // occurrence; should an item repeat a designation, the repeat still becomes a custom field.
24    let mut designations = claimed.designations.to_vec();
25
26    // Top level fields, which 1Password addresses by designation, such as a login's username.
27    for field in details.fields.iter().flatten() {
28        let designation = field.designation.as_deref().unwrap_or_default();
29        if let Some(claim) = designations.iter().position(|taken| *taken == designation) {
30            designations.remove(claim);
31            continue;
32        }
33
34        push_field(
35            &mut fields,
36            field
37                .name
38                .as_deref()
39                .and_then(non_blank)
40                .or(non_blank(designation)),
41            field
42                .value
43                .as_deref()
44                .and_then(non_blank)
45                .map(str::to_string),
46            field.kind.as_deref() == Some("P"),
47        );
48    }
49
50    // Section fields: the rest of the category template plus any sections the user added.
51    for (position, field) in section_fields(details).enumerate() {
52        if claimed.fields.contains(&position) {
53            continue;
54        }
55
56        // Named by the label, else the id. Secrets and SSH keys are hidden.
57        let name = field
58            .name
59            .as_deref()
60            .and_then(non_blank)
61            .or(field.id.as_deref().and_then(non_blank));
62        let hidden = matches!(field.kind.as_deref(), Some("concealed" | "sshKey"));
63        // An SSH key the mapping did not take keeps its material as separate fields.
64        if let Some(attributes) = field
65            .attributes
66            .as_ref()
67            .and_then(|attributes| attributes.ssh_key.as_ref())
68        {
69            push_ssh_attributes(&mut fields, attributes);
70            // The value repeats the private key, which the attributes have just kept.
71            if let Some(private_key) = attributes.private_key.as_deref()
72                && field.value.as_ref().and_then(serde_json::Value::as_str) == Some(private_key)
73            {
74                continue;
75            }
76        }
77        let Some(value) = field.value.as_ref() else {
78            continue;
79        };
80
81        match (field.kind.as_deref(), value) {
82            // An attachment's value is the envelope that unwraps the stored file, encryption keys
83            // included. The file cannot come along, so only its name does. A `file` field holding
84            // anything but an envelope carries no attachment and falls through.
85            (Some("file"), serde_json::Value::Object(_)) => push_field(
86                &mut fields,
87                name,
88                Some(format!("<attachment: {}>", attachment_name(value, name))),
89                false,
90            ),
91            // An address has no single text form, so each part it fills in becomes its own field.
92            // TODO: Should we join the parts into a single field?
93            (Some("address"), serde_json::Value::Object(parts)) => {
94                for (part, value) in parts {
95                    push_field(&mut fields, Some(part), render_value(None, value), hidden);
96                }
97            }
98            // Anything else is a single value, rendered as text by its kind.
99            _ => push_field(
100                &mut fields,
101                name,
102                render_value(field.kind.as_deref(), value),
103                hidden,
104            ),
105        }
106    }
107
108    fields
109}
110
111fn attachment_name<'a>(value: &'a serde_json::Value, fallback: Option<&'a str>) -> &'a str {
112    value
113        .get("fileName")
114        .and_then(serde_json::Value::as_str)
115        .and_then(non_blank)
116        .or(fallback)
117        .unwrap_or("unnamed")
118}
119
120/// Keeps the material of a key the vault could not use, so nothing is lost when the item stays a
121/// note. Only the private key is a secret.
122fn push_ssh_attributes(fields: &mut Vec<Field>, attributes: &SshKeyAttributes) {
123    for (name, value, hidden) in [
124        ("private key", &attributes.private_key, true),
125        ("public key", &attributes.public_key, false),
126        ("fingerprint", &attributes.fingerprint, false),
127    ] {
128        push_field(
129            fields,
130            Some(name),
131            value.as_deref().and_then(non_blank).map(str::to_string),
132            hidden,
133        );
134    }
135}
136
137/// A field is hidden when 1Password treats its value as a secret. The `guarded` attribute is not
138/// that signal: the Identity template sets it on plain fields such as the first name.
139fn push_field(fields: &mut Vec<Field>, name: Option<&str>, value: Option<String>, hidden: bool) {
140    let Some(value) = value else {
141        return;
142    };
143
144    fields.push(Field {
145        name: name.map(str::to_string),
146        value: Some(value),
147        r#type: if hidden { HIDDEN_FIELD } else { TEXT_FIELD },
148        linked_id: None,
149    });
150}