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