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