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