Skip to main content

bitwarden_exporters/cxf/
editable_field.rs

1use bitwarden_vault::FieldType;
2use credential_exchange_format::{
3    EditableField, EditableFieldBoolean, EditableFieldConcealedString, EditableFieldCountryCode,
4    EditableFieldDate, EditableFieldEmail, EditableFieldNumber, EditableFieldString,
5    EditableFieldSubdivisionCode, EditableFieldType as CxfEditableFieldType, EditableFieldValue,
6    EditableFieldWifiNetworkSecurityType, EditableFieldYearMonth, FieldType as CxfFieldType,
7    UnexpectedField,
8};
9
10use crate::Field;
11
12/// Convert a CXF [`EditableField`] into a Bitwarden [`Field`].
13///
14/// The Bitwarden `FieldType` is derived from the value's runtime CXF type rather than from the
15/// statically-expected `T`, so an unexpected payload (e.g. a `boolean` where we expected a
16/// `string`) reports the type that actually arrived — keeping the resulting `Field` coherent.
17pub(super) fn create_field<T>(
18    field: &EditableField<T>,
19    overridden_name: Option<impl Into<String>>,
20) -> Field
21where
22    T: InnerFieldType + CxfEditableFieldType,
23{
24    let value = match field.value.as_expected() {
25        Ok(t) => t.to_field_value(),
26        Err(unexpected) => unexpected.to_field_value(),
27    };
28
29    Field {
30        name: overridden_name
31            .map(Into::into)
32            .or_else(|| field.label.clone()),
33        value: Some(value),
34        r#type: cxf_to_bitwarden_field_type(&field.value.field_type()) as u8,
35        linked_id: None,
36    }
37}
38
39/// Map a CXF [`FieldType`](CxfFieldType) to Bitwarden's narrower [`FieldType`].
40///
41/// CXF distinguishes many semantic field types (Date, YearMonth, CountryCode, …) but Bitwarden's
42/// custom-fields model only has Text/Hidden/Boolean/Linked, so most CXF types collapse to Text.
43fn cxf_to_bitwarden_field_type(cxf_type: &CxfFieldType) -> FieldType {
44    match cxf_type {
45        CxfFieldType::ConcealedString => FieldType::Hidden,
46        CxfFieldType::Boolean => FieldType::Boolean,
47        _ => FieldType::Text,
48    }
49}
50
51/// Helper function to create an EditableField with common properties
52pub(super) fn create_editable_field<T>(name: String, value: T) -> EditableField<T> {
53    EditableField {
54        id: None,
55        label: Some(name),
56        value: value.into(),
57        extensions: None,
58    }
59}
60
61/// Convert Bitwarden Field to CXF EditableFieldValue with proper type mapping
62pub(super) fn field_to_editable_field_value(field: Field) -> Option<EditableFieldValue> {
63    let name = field.name?;
64
65    match field.r#type {
66        x if x == FieldType::Text as u8 => field.value.map(|value| {
67            EditableFieldValue::String(create_editable_field(name, EditableFieldString(value)))
68        }),
69
70        x if x == FieldType::Hidden as u8 => field.value.map(|value| {
71            EditableFieldValue::ConcealedString(create_editable_field(
72                name,
73                EditableFieldConcealedString(value),
74            ))
75        }),
76
77        x if x == FieldType::Boolean as u8 => field.value?.parse::<bool>().ok().map(|bool_value| {
78            EditableFieldValue::Boolean(create_editable_field(
79                name,
80                EditableFieldBoolean(bool_value),
81            ))
82        }),
83
84        x if x == FieldType::Linked as u8 => {
85            let value = field
86                .value
87                .or_else(|| field.linked_id.map(|id| id.to_string()))?;
88            Some(EditableFieldValue::String(create_editable_field(
89                name,
90                EditableFieldString(value),
91            )))
92        }
93
94        _ => field.value.map(|value| {
95            EditableFieldValue::String(create_editable_field(name, EditableFieldString(value)))
96        }),
97    }
98}
99
100/// Bitwarden-side string formatting for a CXF inner field type.
101///
102/// Defaults to the upstream `Into<String>` (which produces the spec wire form), so most types only
103/// need a marker `impl InnerFieldType for X {}`. Override `to_field_value` only when the
104/// vault-facing form should differ from the wire form (e.g. WiFi security types).
105pub(super) trait InnerFieldType: Clone + Into<String> {
106    fn to_field_value(&self) -> String {
107        self.clone().into()
108    }
109}
110
111impl InnerFieldType for EditableFieldString {}
112impl InnerFieldType for EditableFieldConcealedString {}
113impl InnerFieldType for EditableFieldBoolean {}
114impl InnerFieldType for EditableFieldDate {}
115impl InnerFieldType for EditableFieldYearMonth {}
116impl InnerFieldType for EditableFieldCountryCode {}
117impl InnerFieldType for EditableFieldSubdivisionCode {}
118impl InnerFieldType for EditableFieldEmail {}
119impl InnerFieldType for EditableFieldNumber {}
120
121impl InnerFieldType for EditableFieldWifiNetworkSecurityType {
122    fn to_field_value(&self) -> String {
123        use EditableFieldWifiNetworkSecurityType::*;
124        match self {
125            Unsecured => "Unsecured",
126            WpaPersonal => "WPA Personal",
127            Wpa2Personal => "WPA2 Personal",
128            Wpa3Personal => "WPA3 Personal",
129            Wep => "WEP",
130            Other(s) => s,
131            _ => "Unknown",
132        }
133        .to_string()
134    }
135}
136
137/// Unexpected payloads share the same string-formatting contract as the expected path. Dispatch
138/// each variant through its inner [`InnerFieldType::to_field_value`] so future overrides
139/// (currently only WiFi differs from upstream's `Into<String>`) apply uniformly to both paths.
140impl InnerFieldType for UnexpectedField {
141    fn to_field_value(&self) -> String {
142        match self {
143            UnexpectedField::String(v) => v.to_field_value(),
144            UnexpectedField::ConcealedString(v) => v.to_field_value(),
145            UnexpectedField::Boolean(v) => v.to_field_value(),
146            UnexpectedField::Date(v) => v.to_field_value(),
147            UnexpectedField::YearMonth(v) => v.to_field_value(),
148            UnexpectedField::SubdivisionCode(v) => v.to_field_value(),
149            UnexpectedField::CountryCode(v) => v.to_field_value(),
150            UnexpectedField::WifiNetworkSecurityType(v) => v.to_field_value(),
151            UnexpectedField::Email(v) => v.to_field_value(),
152            UnexpectedField::Number(v) => v.to_field_value(),
153            UnexpectedField::Unknown { value, .. } => value.clone(),
154            _ => self.clone().into(),
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_create_field_string() {
165        let editable_field = EditableField {
166            id: None,
167            label: None,
168            value: EditableFieldString("Test Value".to_string()).into(),
169            extensions: None,
170        };
171
172        let field = create_field(&editable_field, Some("Test Name"));
173
174        assert_eq!(
175            field,
176            Field {
177                name: Some("Test Name".to_string()),
178                value: Some("Test Value".to_string()),
179                r#type: FieldType::Text as u8,
180                linked_id: None,
181            }
182        );
183    }
184
185    #[test]
186    fn test_create_field_concealed_string() {
187        let editable_field = EditableField {
188            id: None,
189            label: None,
190            value: EditableFieldConcealedString("Secret123".to_string()).into(),
191            extensions: None,
192        };
193
194        let field = create_field(&editable_field, Some("Password"));
195
196        assert_eq!(
197            field,
198            Field {
199                name: Some("Password".to_string()),
200                value: Some("Secret123".to_string()),
201                r#type: FieldType::Hidden as u8,
202                linked_id: None,
203            }
204        );
205    }
206
207    #[test]
208    fn test_create_field_boolean_true() {
209        let editable_field = EditableField {
210            id: None,
211            label: None,
212            value: EditableFieldBoolean(true).into(),
213            extensions: None,
214        };
215
216        let field = create_field(&editable_field, Some("Is Enabled"));
217
218        assert_eq!(
219            field,
220            Field {
221                name: Some("Is Enabled".to_string()),
222                value: Some("true".to_string()),
223                r#type: FieldType::Boolean as u8,
224                linked_id: None,
225            }
226        );
227    }
228
229    #[test]
230    fn test_create_field_boolean_false() {
231        let editable_field = EditableField {
232            id: None,
233            label: None,
234            value: EditableFieldBoolean(false).into(),
235            extensions: None,
236        };
237
238        let field = create_field(&editable_field, Some("Is Hidden"));
239
240        assert_eq!(
241            field,
242            Field {
243                name: Some("Is Hidden".to_string()),
244                value: Some("false".to_string()),
245                r#type: FieldType::Boolean as u8,
246                linked_id: None,
247            }
248        );
249    }
250
251    #[test]
252    fn test_create_field_wifi_security() {
253        let editable_field = EditableField {
254            id: None,
255            label: None,
256            value: EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
257            extensions: None,
258        };
259
260        let field = create_field(&editable_field, Some("WiFi Security"));
261
262        assert_eq!(
263            field,
264            Field {
265                name: Some("WiFi Security".to_string()),
266                value: Some("WPA3 Personal".to_string()),
267                r#type: FieldType::Text as u8,
268                linked_id: None,
269            }
270        );
271    }
272
273    #[test]
274    fn test_create_field_email() {
275        let editable_field = EditableField {
276            id: None,
277            label: None,
278            value: EditableFieldEmail("[email protected]".to_string()).into(),
279            extensions: None,
280        };
281
282        let field = create_field(&editable_field, Some("Email"));
283
284        assert_eq!(
285            field,
286            Field {
287                name: Some("Email".to_string()),
288                value: Some("[email protected]".to_string()),
289                r#type: FieldType::Text as u8,
290                linked_id: None,
291            }
292        );
293    }
294
295    #[test]
296    fn test_wifi_security_to_field_value() {
297        assert_eq!(
298            EditableFieldWifiNetworkSecurityType::Unsecured.to_field_value(),
299            "Unsecured"
300        );
301        assert_eq!(
302            EditableFieldWifiNetworkSecurityType::WpaPersonal.to_field_value(),
303            "WPA Personal"
304        );
305        assert_eq!(
306            EditableFieldWifiNetworkSecurityType::Wpa2Personal.to_field_value(),
307            "WPA2 Personal"
308        );
309        assert_eq!(
310            EditableFieldWifiNetworkSecurityType::Wpa3Personal.to_field_value(),
311            "WPA3 Personal"
312        );
313        assert_eq!(
314            EditableFieldWifiNetworkSecurityType::Wep.to_field_value(),
315            "WEP"
316        );
317        assert_eq!(
318            EditableFieldWifiNetworkSecurityType::Other("WPA2 Enterprise".to_string())
319                .to_field_value(),
320            "WPA2 Enterprise"
321        );
322    }
323
324    #[test]
325    fn test_create_field_date() {
326        use chrono::NaiveDate;
327
328        let editable_field = EditableField {
329            id: None,
330            label: None,
331            value: EditableFieldDate(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()).into(),
332            extensions: None,
333        };
334
335        let field = create_field(&editable_field, Some("Expiry Date".to_string()));
336
337        assert_eq!(
338            field,
339            Field {
340                name: Some("Expiry Date".to_string()),
341                value: Some("2025-01-15".to_string()),
342                r#type: FieldType::Text as u8,
343                linked_id: None,
344            }
345        );
346    }
347
348    #[test]
349    fn test_create_field_year_month() {
350        use chrono::Month;
351
352        let editable_field = EditableField {
353            id: None,
354            label: None,
355            value: EditableFieldYearMonth {
356                year: 2025,
357                month: Month::December,
358            }
359            .into(),
360            extensions: None,
361        };
362
363        let field = create_field(&editable_field, Some("Card Expiry"));
364
365        assert_eq!(
366            field,
367            Field {
368                name: Some("Card Expiry".to_string()),
369                value: Some("2025-12".to_string()),
370                r#type: FieldType::Text as u8,
371                linked_id: None,
372            }
373        );
374    }
375
376    #[test]
377    fn test_create_field_with_none_name_uses_label() {
378        let editable_field = EditableField {
379            id: None,
380            label: Some("Label From Field".to_string()),
381            value: EditableFieldString("Test Value".to_string()).into(),
382            extensions: None,
383        };
384
385        let field = create_field(&editable_field, None::<String>);
386
387        assert_eq!(
388            field,
389            Field {
390                name: Some("Label From Field".to_string()),
391                value: Some("Test Value".to_string()),
392                r#type: FieldType::Text as u8,
393                linked_id: None,
394            }
395        );
396    }
397
398    /// When deserialization produces an `Expected::Unexpected` (e.g. a payload claims
399    /// `fieldType: boolean` for a slot statically typed as `string`), `create_field` should
400    /// preserve the raw string value AND report the actual incoming type.
401    #[test]
402    fn test_create_field_unexpected_type_reports_actual_type() {
403        let editable_field: EditableField<EditableFieldString> =
404            serde_json::from_value(serde_json::json!({
405                "fieldType": "boolean",
406                "value": "true",
407            }))
408            .unwrap();
409
410        let field = create_field(&editable_field, Some("Mismatched"));
411
412        assert_eq!(
413            field,
414            Field {
415                name: Some("Mismatched".to_string()),
416                value: Some("true".to_string()),
417                r#type: FieldType::Boolean as u8,
418                linked_id: None,
419            }
420        );
421    }
422
423    /// A `concealed-string` payload arriving in a `string` slot should surface as a Hidden field.
424    #[test]
425    fn test_create_field_unexpected_concealed_string_in_string_slot() {
426        let editable_field: EditableField<EditableFieldString> =
427            serde_json::from_value(serde_json::json!({
428                "fieldType": "concealed-string",
429                "value": "secret",
430            }))
431            .unwrap();
432
433        let field = create_field(&editable_field, Some("Hidden Surprise"));
434
435        assert_eq!(
436            field,
437            Field {
438                name: Some("Hidden Surprise".to_string()),
439                value: Some("secret".to_string()),
440                r#type: FieldType::Hidden as u8,
441                linked_id: None,
442            }
443        );
444    }
445
446    /// Unexpected payloads share the per-variant formatting with the expected path, so a
447    /// `WifiNetworkSecurityType` arriving in a `string` slot still renders in vault-display form
448    /// ("WPA3 Personal") rather than wire form ("wpa3-personal").
449    #[test]
450    fn test_create_field_unexpected_wifi_security_renders_in_vault_form() {
451        let editable_field: EditableField<EditableFieldString> =
452            serde_json::from_value(serde_json::json!({
453                "fieldType": "wifi-network-security-type",
454                "value": "wpa3-personal",
455            }))
456            .unwrap();
457
458        let field = create_field(&editable_field, Some("WiFi"));
459
460        assert_eq!(field.value, Some("WPA3 Personal".to_string()));
461    }
462
463    /// An `Unknown` CXF field type (e.g. one we don't model) should fall back to Text.
464    #[test]
465    fn test_create_field_unknown_cxf_type_falls_back_to_text() {
466        let editable_field: EditableField<EditableFieldString> =
467            serde_json::from_value(serde_json::json!({
468                "fieldType": "some-future-type",
469                "value": "future-value",
470            }))
471            .unwrap();
472
473        let field = create_field(&editable_field, Some("Future"));
474
475        assert_eq!(
476            field,
477            Field {
478                name: Some("Future".to_string()),
479                value: Some("future-value".to_string()),
480                r#type: FieldType::Text as u8,
481                linked_id: None,
482            }
483        );
484    }
485
486    #[test]
487    fn test_create_field_with_none_name_and_none_label() {
488        let editable_field = EditableField {
489            id: None,
490            label: None,
491            value: EditableFieldString("Test Value".to_string()).into(),
492            extensions: None,
493        };
494
495        let field = create_field(&editable_field, None::<String>);
496
497        assert_eq!(
498            field,
499            Field {
500                name: None,
501                value: Some("Test Value".to_string()),
502                r#type: FieldType::Text as u8,
503                linked_id: None,
504            }
505        );
506    }
507}