Skip to main content

bitwarden_exporters/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::fmt;
4
5use bitwarden_vault::{
6    CipherRepromptType, CipherView, Fido2CredentialFullView, FieldView, FolderId, LoginUriView,
7    UriMatchType,
8};
9use chrono::{DateTime, Utc};
10use uuid::Uuid;
11
12#[cfg(feature = "uniffi")]
13uniffi::setup_scaffolding!();
14#[cfg(feature = "uniffi")]
15mod uniffi_support;
16
17mod csv;
18mod cxf;
19pub use cxf::Account;
20mod encrypted_json;
21mod exporter_client;
22mod json;
23mod models;
24pub use exporter_client::{ExporterClient, ExporterClientExt};
25mod error;
26mod export;
27pub use error::ExportError;
28
29#[allow(missing_docs)]
30#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
31#[cfg_attr(
32    feature = "wasm",
33    derive(serde::Serialize, serde::Deserialize, tsify::Tsify),
34    tsify(into_wasm_abi, from_wasm_abi)
35)]
36pub enum ExportFormat {
37    Csv,
38    Json,
39    EncryptedJson { password: String },
40}
41
42/// Export representation of a Bitwarden folder.
43///
44/// These are mostly duplicated from the `bitwarden` vault models to facilitate a stable export API
45/// that is not tied to the internal vault models. We may revisit this in the future.
46#[allow(missing_docs)]
47pub struct Folder {
48    pub id: Uuid,
49    pub name: String,
50}
51
52/// Export representation of a Bitwarden cipher.
53///
54/// These are mostly duplicated from the `bitwarden` vault models to facilitate a stable export API
55/// that is not tied to the internal vault models. We may revisit this in the future.
56#[allow(missing_docs)]
57#[derive(Clone)]
58pub struct Cipher {
59    pub id: Uuid,
60    pub folder_id: Option<Uuid>,
61
62    pub name: String,
63    pub notes: Option<String>,
64
65    pub r#type: CipherType,
66
67    pub favorite: bool,
68    pub reprompt: u8,
69
70    pub fields: Vec<Field>,
71
72    pub revision_date: DateTime<Utc>,
73    pub creation_date: DateTime<Utc>,
74    pub deleted_date: Option<DateTime<Utc>>,
75}
76
77/// Import representation of a Bitwarden cipher.
78///
79/// These are mostly duplicated from the `bitwarden` vault models to facilitate a stable export API
80/// that is not tied to the internal vault models. We may revisit this in the future.
81#[allow(missing_docs)]
82#[derive(Clone)]
83#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
84pub struct ImportingCipher {
85    pub folder_id: Option<Uuid>,
86
87    pub name: String,
88    pub notes: Option<String>,
89
90    pub r#type: CipherType,
91
92    pub favorite: bool,
93    pub reprompt: u8,
94
95    pub fields: Vec<Field>,
96
97    pub revision_date: DateTime<Utc>,
98    pub creation_date: DateTime<Utc>,
99    pub deleted_date: Option<DateTime<Utc>>,
100}
101
102impl From<Login> for bitwarden_vault::LoginView {
103    fn from(login: Login) -> Self {
104        let l: Vec<LoginUriView> = login
105            .login_uris
106            .into_iter()
107            .map(LoginUriView::from)
108            .collect();
109
110        bitwarden_vault::LoginView {
111            username: login.username,
112            password: login.password,
113            password_revision_date: None,
114            uris: if l.is_empty() { None } else { Some(l) },
115            totp: login.totp,
116            autofill_on_page_load: None,
117            // Fido2Credentials are set by `encrypt_import`.
118            fido2_credentials: None,
119        }
120    }
121}
122
123impl From<SecureNote> for bitwarden_vault::SecureNoteView {
124    fn from(secure_note: SecureNote) -> Self {
125        bitwarden_vault::SecureNoteView {
126            r#type: secure_note.r#type.into(),
127        }
128    }
129}
130
131impl From<Card> for bitwarden_vault::CardView {
132    fn from(card: Card) -> Self {
133        bitwarden_vault::CardView {
134            cardholder_name: card.cardholder_name,
135            brand: card.brand,
136            number: card.number,
137            exp_month: card.exp_month,
138            exp_year: card.exp_year,
139            code: card.code,
140        }
141    }
142}
143
144impl From<Identity> for bitwarden_vault::IdentityView {
145    fn from(identity: Identity) -> Self {
146        bitwarden_vault::IdentityView {
147            title: identity.title,
148            first_name: identity.first_name,
149            middle_name: identity.middle_name,
150            last_name: identity.last_name,
151            address1: identity.address1,
152            address2: identity.address2,
153            address3: identity.address3,
154            city: identity.city,
155            state: identity.state,
156            postal_code: identity.postal_code,
157            country: identity.country,
158            company: identity.company,
159            email: identity.email,
160            phone: identity.phone,
161            ssn: identity.ssn,
162            username: identity.username,
163            passport_number: identity.passport_number,
164            license_number: identity.license_number,
165        }
166    }
167}
168
169impl From<SshKey> for bitwarden_vault::SshKeyView {
170    fn from(ssh_key: SshKey) -> Self {
171        bitwarden_vault::SshKeyView {
172            private_key: ssh_key.private_key,
173            public_key: ssh_key.public_key,
174            fingerprint: ssh_key.fingerprint,
175        }
176    }
177}
178
179impl From<ImportingCipher> for CipherView {
180    fn from(value: ImportingCipher) -> Self {
181        let (cipher_type, login, identity, card, secure_note, ssh_key) = match value.r#type {
182            CipherType::Login(login) => (
183                bitwarden_vault::CipherType::Login,
184                Some((*login).into()),
185                None,
186                None,
187                None,
188                None,
189            ),
190            CipherType::SecureNote(secure_note) => (
191                bitwarden_vault::CipherType::SecureNote,
192                None,
193                None,
194                None,
195                Some((*secure_note).into()),
196                None,
197            ),
198            CipherType::Card(card) => (
199                bitwarden_vault::CipherType::Card,
200                None,
201                None,
202                Some((*card).into()),
203                None,
204                None,
205            ),
206            CipherType::Identity(identity) => (
207                bitwarden_vault::CipherType::Identity,
208                None,
209                Some((*identity).into()),
210                None,
211                None,
212                None,
213            ),
214            CipherType::SshKey(ssh_key) => (
215                bitwarden_vault::CipherType::SshKey,
216                None,
217                None,
218                None,
219                None,
220                Some((*ssh_key).into()),
221            ),
222        };
223
224        Self {
225            id: None,
226            organization_id: None,
227            folder_id: value.folder_id.map(FolderId::new),
228            collection_ids: vec![],
229            key: None,
230            name: value.name,
231            notes: value.notes,
232            r#type: cipher_type,
233            login,
234            identity,
235            card,
236            secure_note,
237            ssh_key,
238            favorite: value.favorite,
239            reprompt: CipherRepromptType::None,
240            organization_use_totp: true,
241            edit: true,
242            permissions: None,
243            view_password: true,
244            local_data: None,
245            attachments: None,
246            attachment_decryption_failures: None,
247            fields: {
248                let fields: Vec<FieldView> = value.fields.into_iter().map(Into::into).collect();
249                if fields.is_empty() {
250                    None
251                } else {
252                    Some(fields)
253                }
254            },
255            password_history: None,
256            creation_date: value.creation_date,
257            deleted_date: None,
258            revision_date: value.revision_date,
259            archived_date: None,
260        }
261    }
262}
263
264impl From<LoginUri> for bitwarden_vault::LoginUriView {
265    fn from(value: LoginUri) -> Self {
266        Self {
267            uri: value.uri,
268            r#match: value.r#match.and_then(|m| match m {
269                0 => Some(UriMatchType::Domain),
270                1 => Some(UriMatchType::Host),
271                2 => Some(UriMatchType::StartsWith),
272                3 => Some(UriMatchType::Exact),
273                4 => Some(UriMatchType::RegularExpression),
274                5 => Some(UriMatchType::Never),
275                _ => None,
276            }),
277            uri_checksum: None,
278        }
279    }
280}
281
282#[allow(missing_docs)]
283#[derive(Clone, Debug, PartialEq, Eq)]
284pub struct Field {
285    pub name: Option<String>,
286    pub value: Option<String>,
287    pub r#type: u8,
288    pub linked_id: Option<u32>,
289}
290
291#[allow(missing_docs)]
292#[derive(Clone)]
293#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
294pub enum CipherType {
295    Login(Box<Login>),
296    SecureNote(Box<SecureNote>),
297    Card(Box<Card>),
298    Identity(Box<Identity>),
299    SshKey(Box<SshKey>),
300}
301
302impl fmt::Display for CipherType {
303    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
304        match self {
305            CipherType::Login(_) => write!(f, "login"),
306            CipherType::SecureNote(_) => write!(f, "note"),
307            CipherType::Card(_) => write!(f, "card"),
308            CipherType::Identity(_) => write!(f, "identity"),
309            CipherType::SshKey(_) => write!(f, "ssh_key"),
310        }
311    }
312}
313
314#[allow(missing_docs)]
315#[derive(Clone)]
316#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
317pub struct Login {
318    pub username: Option<String>,
319    pub password: Option<String>,
320    pub login_uris: Vec<LoginUri>,
321    pub totp: Option<String>,
322
323    pub fido2_credentials: Option<Vec<Fido2Credential>>,
324}
325
326impl Login {
327    /// Sanitizes all login URIs by ensuring they have a proper scheme.
328    ///
329    /// URIs that are already valid are left unchanged. For invalid URIs:
330    /// - If the URI is an IP address, `http://` is prepended.
331    /// - Otherwise, `https://` is prepended (unless it already starts with `http`).
332    pub fn sanitize_uris(&mut self) {
333        for login_uri in &mut self.login_uris {
334            if let Some(uri) = &login_uri.uri {
335                login_uri.uri = Some(sanitize_uri(uri));
336            }
337        }
338    }
339}
340
341/// Sanitizes a single URI string by ensuring it has a proper scheme.
342///
343/// Mirrors the logic from the iOS `fixURLIfNeeded()` method:
344/// 1. If the URI is already a valid URL, return as-is.
345/// 2. If prepending `http://` yields a URL whose host is an IPv4 address, use `http://`.
346/// 3. If the URI doesn't already start with `http`, prepend `https://`.
347/// 4. Otherwise, return as-is.
348fn sanitize_uri(uri: &str) -> String {
349    if let Ok(parsed) = url::Url::parse(uri)
350        && parsed.has_host()
351    {
352        return uri.to_string();
353    }
354
355    let with_http = format!("http://{uri}");
356    if let Ok(parsed) = url::Url::parse(&with_http)
357        && let Some(host) = parsed.host()
358        && matches!(host, url::Host::Ipv4(_))
359    {
360        return with_http;
361    }
362
363    if !uri.starts_with("http://") || !uri.starts_with("https://") {
364        return format!("https://{uri}");
365    }
366
367    uri.to_string()
368}
369
370#[allow(missing_docs)]
371#[derive(Clone, Debug, PartialEq, Eq)]
372pub struct LoginUri {
373    pub uri: Option<String>,
374    pub r#match: Option<u8>,
375}
376
377#[allow(missing_docs)]
378#[derive(Clone)]
379#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
380pub struct Fido2Credential {
381    pub credential_id: String,
382    pub key_type: String,
383    pub key_algorithm: String,
384    pub key_curve: String,
385    pub key_value: String,
386    pub rp_id: String,
387    pub user_handle: Option<String>,
388    pub user_name: Option<String>,
389    pub counter: u32,
390    pub rp_name: Option<String>,
391    pub user_display_name: Option<String>,
392    pub discoverable: String,
393    pub creation_date: DateTime<Utc>,
394}
395
396impl From<Fido2Credential> for Fido2CredentialFullView {
397    fn from(value: Fido2Credential) -> Self {
398        Fido2CredentialFullView {
399            credential_id: value.credential_id,
400            key_type: value.key_type,
401            key_algorithm: value.key_algorithm,
402            key_curve: value.key_curve,
403            key_value: value.key_value,
404            rp_id: value.rp_id,
405            user_handle: value.user_handle,
406            user_name: value.user_name,
407            counter: value.counter.to_string(),
408            rp_name: value.rp_name,
409            user_display_name: value.user_display_name,
410            discoverable: value.discoverable,
411            creation_date: value.creation_date,
412        }
413    }
414}
415
416#[allow(missing_docs)]
417#[derive(Clone)]
418#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
419pub struct Card {
420    pub cardholder_name: Option<String>,
421    pub exp_month: Option<String>,
422    pub exp_year: Option<String>,
423    pub code: Option<String>,
424    pub brand: Option<String>,
425    pub number: Option<String>,
426}
427
428#[allow(missing_docs)]
429#[derive(Clone)]
430#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
431pub struct SecureNote {
432    pub r#type: SecureNoteType,
433}
434
435#[allow(missing_docs)]
436#[derive(Clone)]
437#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
438pub enum SecureNoteType {
439    Generic = 0,
440}
441
442#[allow(missing_docs)]
443#[derive(Clone, Default, Debug, PartialEq, Eq)]
444pub struct Identity {
445    pub title: Option<String>,
446    pub first_name: Option<String>,
447    pub middle_name: Option<String>,
448    pub last_name: Option<String>,
449    pub address1: Option<String>,
450    pub address2: Option<String>,
451    pub address3: Option<String>,
452    pub city: Option<String>,
453    pub state: Option<String>,
454    pub postal_code: Option<String>,
455    pub country: Option<String>,
456    pub company: Option<String>,
457    pub email: Option<String>,
458    pub phone: Option<String>,
459    pub ssn: Option<String>,
460    pub username: Option<String>,
461    pub passport_number: Option<String>,
462    pub license_number: Option<String>,
463}
464
465#[allow(missing_docs)]
466#[derive(Clone)]
467#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
468pub struct SshKey {
469    /// [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key), in PEM encoding.
470    pub private_key: String,
471    /// Ssh public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6)
472    pub public_key: String,
473    /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT`
474    pub fingerprint: String,
475}
476
477#[cfg(test)]
478mod tests {
479    use bitwarden_vault::{CipherType as VaultCipherType, FieldType};
480    use chrono::{DateTime, Utc};
481
482    use super::*;
483
484    #[test]
485    fn test_importing_cipher_to_cipher_view_login() {
486        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
487        let test_folder_id = uuid::Uuid::new_v4();
488
489        let importing_cipher = ImportingCipher {
490            folder_id: Some(test_folder_id),
491            name: "Test Login".to_string(),
492            notes: Some("Test notes".to_string()),
493            r#type: CipherType::Login(Box::new(Login {
494                username: Some("[email protected]".to_string()),
495                password: Some("password123".to_string()),
496                login_uris: vec![LoginUri {
497                    uri: Some("https://example.com".to_string()),
498                    r#match: Some(0), // Domain match
499                }],
500                totp: Some("otpauth://totp/test".to_string()),
501                fido2_credentials: None,
502            })),
503            favorite: true,
504            reprompt: 1,
505            fields: vec![Field {
506                name: Some("CustomField".to_string()),
507                value: Some("CustomValue".to_string()),
508                r#type: 0,
509                linked_id: None,
510            }],
511            revision_date: test_date,
512            creation_date: test_date,
513            deleted_date: None,
514        };
515
516        let cipher_view: CipherView = importing_cipher.into();
517
518        assert_eq!(cipher_view.id, None);
519        assert_eq!(cipher_view.organization_id, None);
520        assert_eq!(
521            cipher_view.folder_id.unwrap().to_string(),
522            test_folder_id.to_string()
523        );
524        assert_eq!(cipher_view.name, "Test Login");
525        assert_eq!(cipher_view.notes.unwrap(), "Test notes");
526        assert_eq!(cipher_view.r#type, VaultCipherType::Login);
527        assert!(cipher_view.favorite);
528        assert_eq!(cipher_view.creation_date, test_date);
529        assert_eq!(cipher_view.revision_date, test_date);
530
531        let fields = cipher_view.fields.unwrap();
532        assert_eq!(fields.len(), 1);
533
534        let field = fields.first().unwrap();
535        assert_eq!(field.name, Some("CustomField".to_string()));
536        assert_eq!(field.value, Some("CustomValue".to_string()));
537        assert_eq!(field.r#type, FieldType::Text);
538        assert_eq!(field.linked_id, None);
539
540        let login = cipher_view.login.expect("Login should be present");
541        assert_eq!(login.username, Some("[email protected]".to_string()));
542        assert_eq!(login.password, Some("password123".to_string()));
543        assert_eq!(login.totp, Some("otpauth://totp/test".to_string()));
544
545        let uris = login.uris.expect("URIs should be present");
546        assert_eq!(uris.len(), 1);
547        assert_eq!(uris[0].uri, Some("https://example.com".to_string()));
548        assert_eq!(uris[0].r#match, Some(bitwarden_vault::UriMatchType::Domain));
549    }
550
551    #[test]
552    fn test_importing_cipher_to_cipher_view_secure_note() {
553        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
554
555        let importing_cipher = ImportingCipher {
556            folder_id: None,
557            name: "My Note".to_string(),
558            notes: Some("This is a secure note".to_string()),
559            r#type: CipherType::SecureNote(Box::new(SecureNote {
560                r#type: SecureNoteType::Generic,
561            })),
562            favorite: false,
563            reprompt: 0,
564            fields: vec![],
565            revision_date: test_date,
566            creation_date: test_date,
567            deleted_date: None,
568        };
569
570        let cipher_view: CipherView = importing_cipher.into();
571
572        // Verify basic fields
573        assert_eq!(cipher_view.id, None);
574        assert_eq!(cipher_view.organization_id, None);
575        assert_eq!(cipher_view.folder_id, None);
576        assert_eq!(cipher_view.name, "My Note");
577        assert_eq!(cipher_view.notes, Some("This is a secure note".to_string()));
578        assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::SecureNote);
579        assert!(!cipher_view.favorite);
580        assert_eq!(cipher_view.creation_date, test_date);
581        assert_eq!(cipher_view.revision_date, test_date);
582
583        // For SecureNote type, secure_note should be populated and others should be None
584        assert!(cipher_view.login.is_none());
585        assert!(cipher_view.identity.is_none());
586        assert!(cipher_view.card.is_none());
587        assert!(cipher_view.secure_note.is_some());
588        assert!(cipher_view.ssh_key.is_none());
589
590        // Verify the secure note content
591        let secure_note = cipher_view.secure_note.unwrap();
592        assert!(matches!(
593            secure_note.r#type,
594            bitwarden_vault::SecureNoteType::Generic
595        ));
596    }
597
598    #[test]
599    fn test_importing_cipher_to_cipher_view_card() {
600        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
601
602        let importing_cipher = ImportingCipher {
603            folder_id: None,
604            name: "My Credit Card".to_string(),
605            notes: Some("Credit card notes".to_string()),
606            r#type: CipherType::Card(Box::new(Card {
607                cardholder_name: Some("John Doe".to_string()),
608                brand: Some("Visa".to_string()),
609                number: Some("1234567812345678".to_string()),
610                exp_month: Some("12".to_string()),
611                exp_year: Some("2025".to_string()),
612                code: Some("123".to_string()),
613            })),
614            favorite: false,
615            reprompt: 0,
616            fields: vec![],
617            revision_date: test_date,
618            creation_date: test_date,
619            deleted_date: None,
620        };
621
622        let cipher_view: CipherView = importing_cipher.into();
623
624        assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Card);
625        assert!(cipher_view.card.is_some());
626        assert!(cipher_view.login.is_none());
627
628        let card = cipher_view.card.unwrap();
629        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
630        assert_eq!(card.brand, Some("Visa".to_string()));
631        assert_eq!(card.number, Some("1234567812345678".to_string()));
632    }
633
634    #[test]
635    fn test_importing_cipher_to_cipher_view_identity() {
636        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
637
638        let importing_cipher = ImportingCipher {
639            folder_id: None,
640            name: "My Identity".to_string(),
641            notes: None,
642            r#type: CipherType::Identity(Box::new(Identity {
643                title: Some("Dr.".to_string()),
644                first_name: Some("Jane".to_string()),
645                last_name: Some("Smith".to_string()),
646                email: Some("[email protected]".to_string()),
647                ..Default::default()
648            })),
649            favorite: false,
650            reprompt: 0,
651            fields: vec![],
652            revision_date: test_date,
653            creation_date: test_date,
654            deleted_date: None,
655        };
656
657        let cipher_view: CipherView = importing_cipher.into();
658
659        assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Identity);
660        assert!(cipher_view.identity.is_some());
661        assert!(cipher_view.login.is_none());
662
663        let identity = cipher_view.identity.unwrap();
664        assert_eq!(identity.title, Some("Dr.".to_string()));
665        assert_eq!(identity.first_name, Some("Jane".to_string()));
666        assert_eq!(identity.last_name, Some("Smith".to_string()));
667        assert_eq!(identity.email, Some("[email protected]".to_string()));
668    }
669
670    #[test]
671    fn test_sanitize_uri_valid_url_unchanged() {
672        assert_eq!(
673            sanitize_uri("https://bitwarden.com"),
674            "https://bitwarden.com"
675        );
676        assert_eq!(
677            sanitize_uri("https://bitwarden.com/path?q=1"),
678            "https://bitwarden.com/path?q=1"
679        );
680        assert_eq!(
681            sanitize_uri("http://192.168.0.1:8080"),
682            "http://192.168.0.1:8080"
683        );
684    }
685
686    #[test]
687    fn test_sanitize_uri_ip_address_gets_http() {
688        assert_eq!(sanitize_uri("192.168.0.1:8080"), "http://192.168.0.1:8080");
689        assert_eq!(sanitize_uri("10.0.0.1"), "http://10.0.0.1");
690        assert_eq!(
691            sanitize_uri("192.168.0.1:8080/path"),
692            "http://192.168.0.1:8080/path"
693        );
694    }
695
696    #[test]
697    fn test_sanitize_uri_non_ip_gets_https() {
698        assert_eq!(sanitize_uri("bitwarden.com"), "https://bitwarden.com");
699        assert_eq!(
700            sanitize_uri("bitwarden.co.uk/login"),
701            "https://bitwarden.co.uk/login"
702        );
703    }
704
705    #[test]
706    fn test_sanitize_uri_broken_http_prefix_unchanged() {
707        // testing parity with iOS logic: expect http:// prepended to input
708        assert_eq!(
709            sanitize_uri("ht tp://bitwarden.com"),
710            "https://ht tp://bitwarden.com"
711        );
712    }
713
714    #[test]
715    fn test_sanitize_uri_schemeless_host_port() {
716        assert_eq!(sanitize_uri("localhost:8080"), "https://localhost:8080");
717    }
718
719    #[test]
720    fn test_sanitize_uri_scheme_on_string() {
721        assert_eq!(sanitize_uri("foo:bar"), "https://foo:bar");
722    }
723
724    #[test]
725    fn test_sanitize_uri_with_real_schemes_no_change() {
726        assert_eq!(
727            sanitize_uri("ftp://files.example.com"),
728            "ftp://files.example.com"
729        );
730        assert_eq!(
731            sanitize_uri("androidapp://com.example"),
732            "androidapp://com.example"
733        );
734    }
735
736    #[test]
737    fn test_login_sanitize_uris() {
738        let mut login = Login {
739            username: None,
740            password: None,
741            login_uris: vec![
742                LoginUri {
743                    uri: Some("https://bitwarden.com".to_string()),
744                    r#match: None,
745                },
746                LoginUri {
747                    uri: Some("192.168.0.1:8080".to_string()),
748                    r#match: None,
749                },
750                LoginUri {
751                    uri: Some("example.com".to_string()),
752                    r#match: None,
753                },
754                LoginUri {
755                    uri: None,
756                    r#match: None,
757                },
758            ],
759            totp: None,
760            fido2_credentials: None,
761        };
762
763        login.sanitize_uris();
764
765        assert_eq!(
766            login.login_uris[0].uri,
767            Some("https://bitwarden.com".to_string())
768        );
769        assert_eq!(
770            login.login_uris[1].uri,
771            Some("http://192.168.0.1:8080".to_string())
772        );
773        assert_eq!(
774            login.login_uris[2].uri,
775            Some("https://example.com".to_string())
776        );
777        assert_eq!(login.login_uris[3].uri, None);
778    }
779}