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#[allow(missing_docs)]
47pub struct Folder {
48 pub id: Uuid,
49 pub name: String,
50}
51
52#[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#[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#[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 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 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
385fn 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 pub private_key: String,
515 pub public_key: String,
517 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), }],
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 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 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 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 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}