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