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            CipherType::BankAccount => (
223                bitwarden_vault::CipherType::BankAccount,
224                None,
225                None,
226                None,
227                None,
228                None,
229            ),
230        };
231
232        Self {
233            id: None,
234            organization_id: None,
235            folder_id: value.folder_id.map(FolderId::new),
236            collection_ids: vec![],
237            key: None,
238            name: value.name,
239            notes: value.notes,
240            r#type: cipher_type,
241            login,
242            identity,
243            card,
244            secure_note,
245            ssh_key,
246            bank_account: None,
247            favorite: value.favorite,
248            reprompt: CipherRepromptType::None,
249            organization_use_totp: true,
250            edit: true,
251            permissions: None,
252            view_password: true,
253            local_data: None,
254            attachments: None,
255            attachment_decryption_failures: None,
256            fields: {
257                let fields: Vec<FieldView> = value.fields.into_iter().map(Into::into).collect();
258                if fields.is_empty() {
259                    None
260                } else {
261                    Some(fields)
262                }
263            },
264            password_history: None,
265            creation_date: value.creation_date,
266            deleted_date: None,
267            revision_date: value.revision_date,
268            archived_date: None,
269        }
270    }
271}
272
273impl From<LoginUri> for bitwarden_vault::LoginUriView {
274    fn from(value: LoginUri) -> Self {
275        Self {
276            uri: value.uri,
277            r#match: value.r#match.and_then(|m| match m {
278                0 => Some(UriMatchType::Domain),
279                1 => Some(UriMatchType::Host),
280                2 => Some(UriMatchType::StartsWith),
281                3 => Some(UriMatchType::Exact),
282                4 => Some(UriMatchType::RegularExpression),
283                5 => Some(UriMatchType::Never),
284                _ => None,
285            }),
286            uri_checksum: None,
287        }
288    }
289}
290
291#[allow(missing_docs)]
292#[derive(Clone, Debug, PartialEq, Eq)]
293pub struct Field {
294    pub name: Option<String>,
295    pub value: Option<String>,
296    pub r#type: u8,
297    pub linked_id: Option<u32>,
298}
299
300#[allow(missing_docs)]
301#[derive(Clone)]
302#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
303pub enum CipherType {
304    Login(Box<Login>),
305    SecureNote(Box<SecureNote>),
306    Card(Box<Card>),
307    Identity(Box<Identity>),
308    SshKey(Box<SshKey>),
309    BankAccount,
310}
311
312impl fmt::Display for CipherType {
313    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
314        match self {
315            CipherType::Login(_) => write!(f, "login"),
316            CipherType::SecureNote(_) => write!(f, "note"),
317            CipherType::Card(_) => write!(f, "card"),
318            CipherType::Identity(_) => write!(f, "identity"),
319            CipherType::SshKey(_) => write!(f, "ssh_key"),
320            CipherType::BankAccount => write!(f, "bank_account"),
321        }
322    }
323}
324
325#[allow(missing_docs)]
326#[derive(Clone)]
327#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
328pub struct Login {
329    pub username: Option<String>,
330    pub password: Option<String>,
331    pub login_uris: Vec<LoginUri>,
332    pub totp: Option<String>,
333
334    pub fido2_credentials: Option<Vec<Fido2Credential>>,
335}
336
337impl Login {
338    /// Sanitizes all login URIs by ensuring they have a proper scheme.
339    ///
340    /// URIs that are already valid are left unchanged. For invalid URIs:
341    /// - If the URI is an IP address, `http://` is prepended.
342    /// - Otherwise, `https://` is prepended (unless it already starts with `http`).
343    pub fn sanitize_uris(&mut self) {
344        for login_uri in &mut self.login_uris {
345            if let Some(uri) = &login_uri.uri {
346                login_uri.uri = Some(sanitize_uri(uri));
347            }
348        }
349    }
350}
351
352/// Sanitizes a single URI string by ensuring it has a proper scheme.
353///
354/// Mirrors the logic from the iOS `fixURLIfNeeded()` method:
355/// 1. If the URI is already a valid URL, return as-is.
356/// 2. If prepending `http://` yields a URL whose host is an IPv4 address, use `http://`.
357/// 3. If the URI doesn't already start with `http`, prepend `https://`.
358/// 4. Otherwise, return as-is.
359fn sanitize_uri(uri: &str) -> String {
360    if let Ok(parsed) = url::Url::parse(uri)
361        && parsed.has_host()
362    {
363        return uri.to_string();
364    }
365
366    let with_http = format!("http://{uri}");
367    if let Ok(parsed) = url::Url::parse(&with_http)
368        && let Some(host) = parsed.host()
369        && matches!(host, url::Host::Ipv4(_))
370    {
371        return with_http;
372    }
373
374    if !uri.starts_with("http://") || !uri.starts_with("https://") {
375        return format!("https://{uri}");
376    }
377
378    uri.to_string()
379}
380
381#[allow(missing_docs)]
382#[derive(Clone, Debug, PartialEq, Eq)]
383pub struct LoginUri {
384    pub uri: Option<String>,
385    pub r#match: Option<u8>,
386}
387
388#[allow(missing_docs)]
389#[derive(Clone)]
390#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
391pub struct Fido2Credential {
392    pub credential_id: String,
393    pub key_type: String,
394    pub key_algorithm: String,
395    pub key_curve: String,
396    pub key_value: String,
397    pub rp_id: String,
398    pub user_handle: Option<String>,
399    pub user_name: Option<String>,
400    pub counter: u32,
401    pub rp_name: Option<String>,
402    pub user_display_name: Option<String>,
403    pub discoverable: String,
404    pub creation_date: DateTime<Utc>,
405}
406
407impl From<Fido2Credential> for Fido2CredentialFullView {
408    fn from(value: Fido2Credential) -> Self {
409        Fido2CredentialFullView {
410            credential_id: value.credential_id,
411            key_type: value.key_type,
412            key_algorithm: value.key_algorithm,
413            key_curve: value.key_curve,
414            key_value: value.key_value,
415            rp_id: value.rp_id,
416            user_handle: value.user_handle,
417            user_name: value.user_name,
418            counter: value.counter.to_string(),
419            rp_name: value.rp_name,
420            user_display_name: value.user_display_name,
421            discoverable: value.discoverable,
422            creation_date: value.creation_date,
423        }
424    }
425}
426
427#[allow(missing_docs)]
428#[derive(Clone)]
429#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
430pub struct Card {
431    pub cardholder_name: Option<String>,
432    pub exp_month: Option<String>,
433    pub exp_year: Option<String>,
434    pub code: Option<String>,
435    pub brand: Option<String>,
436    pub number: Option<String>,
437}
438
439#[allow(missing_docs)]
440#[derive(Clone)]
441#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
442pub struct SecureNote {
443    pub r#type: SecureNoteType,
444}
445
446#[allow(missing_docs)]
447#[derive(Clone)]
448#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
449pub enum SecureNoteType {
450    Generic = 0,
451}
452
453#[allow(missing_docs)]
454#[derive(Clone, Default, Debug, PartialEq, Eq)]
455pub struct Identity {
456    pub title: Option<String>,
457    pub first_name: Option<String>,
458    pub middle_name: Option<String>,
459    pub last_name: Option<String>,
460    pub address1: Option<String>,
461    pub address2: Option<String>,
462    pub address3: Option<String>,
463    pub city: Option<String>,
464    pub state: Option<String>,
465    pub postal_code: Option<String>,
466    pub country: Option<String>,
467    pub company: Option<String>,
468    pub email: Option<String>,
469    pub phone: Option<String>,
470    pub ssn: Option<String>,
471    pub username: Option<String>,
472    pub passport_number: Option<String>,
473    pub license_number: Option<String>,
474}
475
476#[allow(missing_docs)]
477#[derive(Clone)]
478#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
479pub struct SshKey {
480    /// [OpenSSH private key](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key), in PEM encoding.
481    pub private_key: String,
482    /// Ssh public key (ed25519/rsa) according to [RFC4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.6)
483    pub public_key: String,
484    /// SSH fingerprint using SHA256 in the format: `SHA256:BASE64_ENCODED_FINGERPRINT`
485    pub fingerprint: String,
486}
487
488#[cfg(test)]
489mod tests {
490    use bitwarden_vault::{CipherType as VaultCipherType, FieldType};
491    use chrono::{DateTime, Utc};
492
493    use super::*;
494
495    #[test]
496    fn test_importing_cipher_to_cipher_view_login() {
497        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
498        let test_folder_id = uuid::Uuid::new_v4();
499
500        let importing_cipher = ImportingCipher {
501            folder_id: Some(test_folder_id),
502            name: "Test Login".to_string(),
503            notes: Some("Test notes".to_string()),
504            r#type: CipherType::Login(Box::new(Login {
505                username: Some("[email protected]".to_string()),
506                password: Some("password123".to_string()),
507                login_uris: vec![LoginUri {
508                    uri: Some("https://example.com".to_string()),
509                    r#match: Some(0), // Domain match
510                }],
511                totp: Some("otpauth://totp/test".to_string()),
512                fido2_credentials: None,
513            })),
514            favorite: true,
515            reprompt: 1,
516            fields: vec![Field {
517                name: Some("CustomField".to_string()),
518                value: Some("CustomValue".to_string()),
519                r#type: 0,
520                linked_id: None,
521            }],
522            revision_date: test_date,
523            creation_date: test_date,
524            deleted_date: None,
525        };
526
527        let cipher_view: CipherView = importing_cipher.into();
528
529        assert_eq!(cipher_view.id, None);
530        assert_eq!(cipher_view.organization_id, None);
531        assert_eq!(
532            cipher_view.folder_id.unwrap().to_string(),
533            test_folder_id.to_string()
534        );
535        assert_eq!(cipher_view.name, "Test Login");
536        assert_eq!(cipher_view.notes.unwrap(), "Test notes");
537        assert_eq!(cipher_view.r#type, VaultCipherType::Login);
538        assert!(cipher_view.favorite);
539        assert_eq!(cipher_view.creation_date, test_date);
540        assert_eq!(cipher_view.revision_date, test_date);
541
542        let fields = cipher_view.fields.unwrap();
543        assert_eq!(fields.len(), 1);
544
545        let field = fields.first().unwrap();
546        assert_eq!(field.name, Some("CustomField".to_string()));
547        assert_eq!(field.value, Some("CustomValue".to_string()));
548        assert_eq!(field.r#type, FieldType::Text);
549        assert_eq!(field.linked_id, None);
550
551        let login = cipher_view.login.expect("Login should be present");
552        assert_eq!(login.username, Some("[email protected]".to_string()));
553        assert_eq!(login.password, Some("password123".to_string()));
554        assert_eq!(login.totp, Some("otpauth://totp/test".to_string()));
555
556        let uris = login.uris.expect("URIs should be present");
557        assert_eq!(uris.len(), 1);
558        assert_eq!(uris[0].uri, Some("https://example.com".to_string()));
559        assert_eq!(uris[0].r#match, Some(bitwarden_vault::UriMatchType::Domain));
560    }
561
562    #[test]
563    fn test_importing_cipher_to_cipher_view_secure_note() {
564        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
565
566        let importing_cipher = ImportingCipher {
567            folder_id: None,
568            name: "My Note".to_string(),
569            notes: Some("This is a secure note".to_string()),
570            r#type: CipherType::SecureNote(Box::new(SecureNote {
571                r#type: SecureNoteType::Generic,
572            })),
573            favorite: false,
574            reprompt: 0,
575            fields: vec![],
576            revision_date: test_date,
577            creation_date: test_date,
578            deleted_date: None,
579        };
580
581        let cipher_view: CipherView = importing_cipher.into();
582
583        // Verify basic fields
584        assert_eq!(cipher_view.id, None);
585        assert_eq!(cipher_view.organization_id, None);
586        assert_eq!(cipher_view.folder_id, None);
587        assert_eq!(cipher_view.name, "My Note");
588        assert_eq!(cipher_view.notes, Some("This is a secure note".to_string()));
589        assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::SecureNote);
590        assert!(!cipher_view.favorite);
591        assert_eq!(cipher_view.creation_date, test_date);
592        assert_eq!(cipher_view.revision_date, test_date);
593
594        // For SecureNote type, secure_note should be populated and others should be None
595        assert!(cipher_view.login.is_none());
596        assert!(cipher_view.identity.is_none());
597        assert!(cipher_view.card.is_none());
598        assert!(cipher_view.secure_note.is_some());
599        assert!(cipher_view.ssh_key.is_none());
600
601        // Verify the secure note content
602        let secure_note = cipher_view.secure_note.unwrap();
603        assert!(matches!(
604            secure_note.r#type,
605            bitwarden_vault::SecureNoteType::Generic
606        ));
607    }
608
609    #[test]
610    fn test_importing_cipher_to_cipher_view_card() {
611        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
612
613        let importing_cipher = ImportingCipher {
614            folder_id: None,
615            name: "My Credit Card".to_string(),
616            notes: Some("Credit card notes".to_string()),
617            r#type: CipherType::Card(Box::new(Card {
618                cardholder_name: Some("John Doe".to_string()),
619                brand: Some("Visa".to_string()),
620                number: Some("1234567812345678".to_string()),
621                exp_month: Some("12".to_string()),
622                exp_year: Some("2025".to_string()),
623                code: Some("123".to_string()),
624            })),
625            favorite: false,
626            reprompt: 0,
627            fields: vec![],
628            revision_date: test_date,
629            creation_date: test_date,
630            deleted_date: None,
631        };
632
633        let cipher_view: CipherView = importing_cipher.into();
634
635        assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Card);
636        assert!(cipher_view.card.is_some());
637        assert!(cipher_view.login.is_none());
638
639        let card = cipher_view.card.unwrap();
640        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
641        assert_eq!(card.brand, Some("Visa".to_string()));
642        assert_eq!(card.number, Some("1234567812345678".to_string()));
643    }
644
645    #[test]
646    fn test_importing_cipher_to_cipher_view_identity() {
647        let test_date: DateTime<Utc> = "2024-01-30T17:55:36.150Z".parse().unwrap();
648
649        let importing_cipher = ImportingCipher {
650            folder_id: None,
651            name: "My Identity".to_string(),
652            notes: None,
653            r#type: CipherType::Identity(Box::new(Identity {
654                title: Some("Dr.".to_string()),
655                first_name: Some("Jane".to_string()),
656                last_name: Some("Smith".to_string()),
657                email: Some("[email protected]".to_string()),
658                ..Default::default()
659            })),
660            favorite: false,
661            reprompt: 0,
662            fields: vec![],
663            revision_date: test_date,
664            creation_date: test_date,
665            deleted_date: None,
666        };
667
668        let cipher_view: CipherView = importing_cipher.into();
669
670        assert_eq!(cipher_view.r#type, bitwarden_vault::CipherType::Identity);
671        assert!(cipher_view.identity.is_some());
672        assert!(cipher_view.login.is_none());
673
674        let identity = cipher_view.identity.unwrap();
675        assert_eq!(identity.title, Some("Dr.".to_string()));
676        assert_eq!(identity.first_name, Some("Jane".to_string()));
677        assert_eq!(identity.last_name, Some("Smith".to_string()));
678        assert_eq!(identity.email, Some("[email protected]".to_string()));
679    }
680
681    #[test]
682    fn test_sanitize_uri_valid_url_unchanged() {
683        assert_eq!(
684            sanitize_uri("https://bitwarden.com"),
685            "https://bitwarden.com"
686        );
687        assert_eq!(
688            sanitize_uri("https://bitwarden.com/path?q=1"),
689            "https://bitwarden.com/path?q=1"
690        );
691        assert_eq!(
692            sanitize_uri("http://192.168.0.1:8080"),
693            "http://192.168.0.1:8080"
694        );
695    }
696
697    #[test]
698    fn test_sanitize_uri_ip_address_gets_http() {
699        assert_eq!(sanitize_uri("192.168.0.1:8080"), "http://192.168.0.1:8080");
700        assert_eq!(sanitize_uri("10.0.0.1"), "http://10.0.0.1");
701        assert_eq!(
702            sanitize_uri("192.168.0.1:8080/path"),
703            "http://192.168.0.1:8080/path"
704        );
705    }
706
707    #[test]
708    fn test_sanitize_uri_non_ip_gets_https() {
709        assert_eq!(sanitize_uri("bitwarden.com"), "https://bitwarden.com");
710        assert_eq!(
711            sanitize_uri("bitwarden.co.uk/login"),
712            "https://bitwarden.co.uk/login"
713        );
714    }
715
716    #[test]
717    fn test_sanitize_uri_broken_http_prefix_unchanged() {
718        // testing parity with iOS logic: expect http:// prepended to input
719        assert_eq!(
720            sanitize_uri("ht tp://bitwarden.com"),
721            "https://ht tp://bitwarden.com"
722        );
723    }
724
725    #[test]
726    fn test_sanitize_uri_schemeless_host_port() {
727        assert_eq!(sanitize_uri("localhost:8080"), "https://localhost:8080");
728    }
729
730    #[test]
731    fn test_sanitize_uri_scheme_on_string() {
732        assert_eq!(sanitize_uri("foo:bar"), "https://foo:bar");
733    }
734
735    #[test]
736    fn test_sanitize_uri_with_real_schemes_no_change() {
737        assert_eq!(
738            sanitize_uri("ftp://files.example.com"),
739            "ftp://files.example.com"
740        );
741        assert_eq!(
742            sanitize_uri("androidapp://com.example"),
743            "androidapp://com.example"
744        );
745    }
746
747    #[test]
748    fn test_login_sanitize_uris() {
749        let mut login = Login {
750            username: None,
751            password: None,
752            login_uris: vec![
753                LoginUri {
754                    uri: Some("https://bitwarden.com".to_string()),
755                    r#match: None,
756                },
757                LoginUri {
758                    uri: Some("192.168.0.1:8080".to_string()),
759                    r#match: None,
760                },
761                LoginUri {
762                    uri: Some("example.com".to_string()),
763                    r#match: None,
764                },
765                LoginUri {
766                    uri: None,
767                    r#match: None,
768                },
769            ],
770            totp: None,
771            fido2_credentials: None,
772        };
773
774        login.sanitize_uris();
775
776        assert_eq!(
777            login.login_uris[0].uri,
778            Some("https://bitwarden.com".to_string())
779        );
780        assert_eq!(
781            login.login_uris[1].uri,
782            Some("http://192.168.0.1:8080".to_string())
783        );
784        assert_eq!(
785            login.login_uris[2].uri,
786            Some("https://example.com".to_string())
787        );
788        assert_eq!(login.login_uris[3].uri, None);
789    }
790}