logins/
login.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5//  N.B. if you're making a documentation change here, you might also want to make it in:
6//
7//    * The API docs in ../ios/Logins/LoginRecord.swift
8//    * The API docs in ../android/src/main/java/mozilla/appservices/logins/ServerPassword.kt
9//    * The android-components docs at
10//      https://github.com/mozilla-mobile/android-components/tree/master/components/service/sync-logins
11//
12//  We'll figure out a more scalable approach to maintaining all those docs at some point...
13
14//! # Login Structs
15//!
16//! This module defines a number of core structs for Logins. They are:
17//! * [`LoginEntry`] A login entry by the user.  This includes the username/password, the site it
18//!   was submitted to, etc.  [`LoginEntry`] does not store data specific to a DB record.
19//! * [`Login`] - A [`LoginEntry`] plus DB record information.  This includes the GUID and metadata
20//!   like time_last_used.
21//! * [`EncryptedLogin`] -- A Login above with the username/password data encrypted.
22//! * [`LoginFields`], [`SecureLoginFields`], [`LoginMeta`] -- These group the common fields in the
23//!   structs above.
24//!
25//! Why so many structs for similar data?  Consider some common use cases in a hypothetical browser
26//! (currently no browsers act exactly like this, although Fenix/android-components comes close):
27//!
28//! - User visits a page with a login form.
29//!   - We inform the user if there are saved logins that can be autofilled.  We use the
30//!     `LoginDb.get_by_base_domain()` which returns a `Vec<EncryptedLogin>`.  We don't decrypt the
31//!     logins because we want to avoid requiring the encryption key at this point, which would
32//!     force the user to authenticate.  Note: this is aspirational at this point, no actual
33//!     implementations follow this flow.  Still, we want application-services to support it.
34//!   - If the user chooses to autofill, we decrypt the logins into a `Vec<Login>`.  We need to
35//!     decrypt at this point to display the username and autofill the password if they select one.
36//!   - When the user selects a login, we can use the already decrypted data from `Login` to fill
37//!     in the form.
38//! - User chooses to save a login for autofilling later.
39//!    - We present the user with a dialog that:
40//!       - Displays a header that differentiates between different types of save: adding a new
41//!         login, updating an existing login, filling in a blank username, etc.
42//!       - Allows the user to tweak the username, in case we failed to detect the form field
43//!         correctly.  This may affect which header should be shown.
44//!    - Here we use `find_login_to_update()` which returns an `Option<Login>`.  Returning a login
45//!      that has decrypted data avoids forcing the consumer code to decrypt the username again.
46//!
47//! # Login
48//! This has the complete set of data about a login. Very closely related is the
49//! "sync payload", defined in sync/payload.rs, which handles all aspects of the JSON serialization.
50//! It contains the following fields:
51//! - `meta`: A [`LoginMeta`] struct.
52//! - fields: A [`LoginFields`] struct.
53//! - sec_fields: A [`SecureLoginFields`] struct.
54//!
55//! # LoginEntry
56//! The struct used to add or update logins. This has the plain-text version of the fields that are
57//! stored encrypted, so almost all uses of an LoginEntry struct will also require the
58//! encryption key to be known and passed in.    [LoginDB] methods that save data typically input
59//! [LoginEntry] instances.  This allows the DB code to handle dupe-checking issues like
60//! determining which login record should be updated for a newly submitted [LoginEntry].
61//! It contains the following fields:
62//! - fields: A [`LoginFields`] struct.
63//! - sec_fields: A [`SecureLoginFields`] struct.
64//!
65//! # EncryptedLogin
66//! Encrypted version of [`Login`].  [LoginDB] methods that return data typically return [EncryptedLogin]
67//! this allows deferring decryption, and therefore user authentication, until the secure data is needed.
68//! It contains the following fields
69//! - `meta`: A [`LoginMeta`] struct.
70//! - `fields`: A [`LoginFields`] struct.
71//! - `sec_fields`: The secure fields as an encrypted string
72//!
73//! # SecureLoginFields
74//! The struct used to hold the fields which are stored encrypted. It contains:
75//! - username: A string.
76//! - password: A string.
77//!
78//! # LoginFields
79//!
80//! The core set of fields, use by both [`Login`] and [`LoginEntry`]
81//! It contains the following fields:
82//!
83//! - `origin`:  The origin at which this login can be used, as a string.
84//!
85//!   The login should only be used on sites that match this origin (for whatever definition
86//!   of "matches" makes sense at the application level, e.g. eTLD+1 matching).
87//!   This field is required, must be a valid origin in punycode format, and must not be
88//!   set to the empty string.
89//!
90//!   Examples of valid `origin` values include:
91//!   - "https://site.com"
92//!   - "http://site.com:1234"
93//!   - "ftp://ftp.site.com"
94//!   - "moz-proxy://127.0.0.1:8888"
95//!   - "chrome://MyLegacyExtension"
96//!   - "file://"
97//!   - "https://\[::1\]"
98//!
99//!   If invalid data is received in this field (either from the application, or via sync)
100//!   then the logins store will attempt to coerce it into valid data by:
101//!   - truncating full URLs to just their origin component, if it is not an opaque origin
102//!   - converting values with non-ascii characters into punycode
103//!
104//!   **XXX TODO:**
105//!   - Add a field with the original unicode versions of the URLs instead of punycode?
106//!
107//! - `sec_fields`: The `username` and `password` for the site, stored as a encrypted JSON
108//!   representation of an `SecureLoginFields`.
109//!
110//!   This field is required and usually encrypted.  There are two different value types:
111//!   - Plaintext empty string: Used for deleted records
112//!   - Encrypted value: The credentials associated with the login.
113//!
114//! - `http_realm`:  The challenge string for HTTP Basic authentication, if any.
115//!
116//!   If present, the login should only be used in response to a HTTP Basic Auth
117//!   challenge that specifies a matching realm. For legacy reasons this string may not
118//!   contain null bytes, carriage returns or newlines.
119//!
120//!   If this field is set to the empty string, this indicates a wildcard match on realm.
121//!
122//!   This field must not be present if `form_action_origin` is set, since they indicate different types
123//!   of login (HTTP-Auth based versus form-based). Exactly one of `http_realm` and `form_action_origin`
124//!   must be present.
125//!
126//! - `form_action_origin`:  The target origin of forms in which this login can be used, if any, as a string.
127//!
128//!   If present, the login should only be used in forms whose target submission URL matches this origin.
129//!   This field must be a valid origin or one of the following special cases:
130//!   - An empty string, which is a wildcard match for any origin.
131//!   - The single character ".", which is equivalent to the empty string
132//!   - The string "javascript:", which matches any form with javascript target URL.
133//!
134//!   This field must not be present if `http_realm` is set, since they indicate different types of login
135//!   (HTTP-Auth based versus form-based). Exactly one of `http_realm` and `form_action_origin` must be present.
136//!
137//!   If invalid data is received in this field (either from the application, or via sync) then the
138//!   logins store will attempt to coerce it into valid data by:
139//!   - truncating full URLs to just their origin component
140//!   - converting origins with non-ascii characters into punycode
141//!   - replacing invalid values with null if a valid 'http_realm' field is present
142//!
143//! - `username_field`:  The name of the form field into which the 'username' should be filled, if any.
144//!
145//!   This value is stored if provided by the application, but does not imply any restrictions on
146//!   how the login may be used in practice. For legacy reasons this string may not contain null
147//!   bytes, carriage returns or newlines. This field must be empty unless `form_action_origin` is set.
148//!
149//!   If invalid data is received in this field (either from the application, or via sync)
150//!   then the logins store will attempt to coerce it into valid data by:
151//!   - setting to the empty string if 'form_action_origin' is not present
152//!
153//! - `password_field`:  The name of the form field into which the 'password' should be filled, if any.
154//!
155//!   This value is stored if provided by the application, but does not imply any restrictions on
156//!   how the login may be used in practice. For legacy reasons this string may not contain null
157//!   bytes, carriage returns or newlines. This field must be empty unless `form_action_origin` is set.
158//!
159//!   If invalid data is received in this field (either from the application, or via sync)
160//!   then the logins store will attempt to coerce it into valid data by:
161//!   - setting to the empty string if 'form_action_origin' is not present
162//!
163//! # LoginMeta
164//!
165//! This contains data relating to the login database record -- both on the local instance and
166//! synced to other browsers.
167//! It contains the following fields:
168//! - `id`:  A unique string identifier for this record.
169//!
170//!   Consumers may assume that `id` contains only "safe" ASCII characters but should otherwise
171//!   treat this it as an opaque identifier. These are generated as needed.
172//!
173//! - `timesUsed`:  A lower bound on the number of times the password from this record has been used, as an integer.
174//!
175//!   Applications should use the `touch()` method of the logins store to indicate when a password
176//!   has been used, and should ensure that they only count uses of the actual `password` field
177//!   (so for example, copying the `password` field to the clipboard should count as a "use", but
178//!   copying just the `username` field should not).
179//!
180//!   This number may not record uses that occurred on other devices, since some legacy
181//!   sync clients do not record this information. It may be zero for records obtained
182//!   via sync that have never been used locally.
183//!
184//!   When merging duplicate records, the two usage counts are summed.
185//!
186//!   This field is managed internally by the logins store by default and does not need to
187//!   be set explicitly, although any application-provided value will be preserved when creating
188//!   a new record.
189//!
190//!   If invalid data is received in this field (either from the application, or via sync)
191//!   then the logins store will attempt to coerce it into valid data by:
192//!   - replacing missing or negative values with 0
193//!
194//!   **XXX TODO:**
195//!   - test that we prevent this counter from moving backwards.
196//!   - test fixups of missing or negative values
197//!   - test that we correctly merge dupes
198//!
199//! - `time_created`: An upper bound on the time of creation of this login, in integer milliseconds from the unix epoch.
200//!
201//!   This is an upper bound because some legacy sync clients do not record this information.
202//!
203//!   Note that this field is typically a timestamp taken from the local machine clock, so it
204//!   may be wildly inaccurate if the client does not have an accurate clock.
205//!
206//!   This field is managed internally by the logins store by default and does not need to
207//!   be set explicitly, although any application-provided value will be preserved when creating
208//!   a new record.
209//!
210//!   When merging duplicate records, the smallest non-zero value is taken.
211//!
212//!   If invalid data is received in this field (either from the application, or via sync)
213//!   then the logins store will attempt to coerce it into valid data by:
214//!   - replacing missing or negative values with the current time
215//!
216//!   **XXX TODO:**
217//!   - test that we prevent this timestamp from moving backwards.
218//!   - test fixups of missing or negative values
219//!   - test that we correctly merge dupes
220//!
221//! - `time_last_used`: A lower bound on the time of last use of this login, in integer milliseconds from the unix epoch.
222//!
223//!   This is a lower bound because some legacy sync clients do not record this information;
224//!   in that case newer clients set `timeLastUsed` when they use the record for the first time.
225//!
226//!   Note that this field is typically a timestamp taken from the local machine clock, so it
227//!   may be wildly inaccurate if the client does not have an accurate clock.
228//!
229//!   This field is managed internally by the logins store by default and does not need to
230//!   be set explicitly, although any application-provided value will be preserved when creating
231//!   a new record.
232//!
233//!   When merging duplicate records, the largest non-zero value is taken.
234//!
235//!   If invalid data is received in this field (either from the application, or via sync)
236//!   then the logins store will attempt to coerce it into valid data by:
237//!   - removing negative values
238//!
239//!   **XXX TODO:**
240//!   - test that we prevent this timestamp from moving backwards.
241//!   - test fixups of missing or negative values
242//!   - test that we correctly merge dupes
243//!
244//! - `time_password_changed`: A lower bound on the time that the `password` field was last changed, in integer
245//!   milliseconds from the unix epoch.
246//!
247//!   Changes to other fields (such as `username`) are not reflected in this timestamp.
248//!   This is a lower bound because some legacy sync clients do not record this information;
249//!   in that case newer clients set `time_password_changed` when they change the `password` field.
250//!
251//!   Note that this field is typically a timestamp taken from the local machine clock, so it
252//!   may be wildly inaccurate if the client does not have an accurate clock.
253//!
254//!   This field is managed internally by the logins store by default and does not need to
255//!   be set explicitly, although any application-provided value will be preserved when creating
256//!   a new record.
257//!
258//!   When merging duplicate records, the largest non-zero value is taken.
259//!
260//!   If invalid data is received in this field (either from the application, or via sync)
261//!   then the logins store will attempt to coerce it into valid data by:
262//!   - removing negative values
263//!
264//!   **XXX TODO:**
265//!   - test that we prevent this timestamp from moving backwards.
266//!   - test that we don't set this for changes to other fields.
267//!   - test that we correctly merge dupes
268//!
269//!
270//! In order to deal with data from legacy clients in a robust way, it is necessary to be able to build
271//! and manipulate all these `Login` structs that contain invalid data.  The non-encrypted structs
272//! implement the `ValidateAndFixup` trait, providing the following methods which can be used by
273//! callers to ensure that they're only working with valid records:
274//!
275//! - `Login::check_valid()`:    Checks validity of a login record, returning `()` if it is valid
276//!   or an error if it is not.
277//!
278//! - `Login::fixup()`:   Returns either the existing login if it is valid, a clone with invalid fields
279//!   fixed up if it was safe to do so, or an error if the login is irreparably invalid.
280
281use crate::{encryption::EncryptorDecryptor, error::*};
282use rusqlite::Row;
283use serde_derive::*;
284use sync_guid::Guid;
285use url::Url;
286
287// LoginEntry fields that are stored in cleartext
288#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
289pub struct LoginFields {
290    pub origin: String,
291    pub form_action_origin: Option<String>,
292    pub http_realm: Option<String>,
293    pub username_field: String,
294    pub password_field: String,
295}
296
297/// LoginEntry fields that are stored encrypted
298#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
299pub struct SecureLoginFields {
300    // - Username cannot be null, use the empty string instead
301    // - Password can't be empty or null (enforced in the ValidateAndFixup code)
302    //
303    // This matches the desktop behavior:
304    // https://searchfox.org/mozilla-central/rev/d3683dbb252506400c71256ef3994cdbdfb71ada/toolkit/components/passwordmgr/LoginManager.jsm#260-267
305
306    // Because we store the json version of this in the DB, and that's the only place the json
307    // is used, we rename the fields to short names, just to reduce the overhead in the DB.
308    #[serde(rename = "u")]
309    pub username: String,
310    #[serde(rename = "p")]
311    pub password: String,
312}
313
314impl SecureLoginFields {
315    pub fn encrypt(&self, encdec: &dyn EncryptorDecryptor, login_id: &str) -> Result<String> {
316        let string = serde_json::to_string(&self)?;
317        let cipherbytes = encdec
318            .encrypt(string.as_bytes().into())
319            .map_err(|e| Error::EncryptionFailed(format!("{e} (encrypting {login_id})")))?;
320        let ciphertext = std::str::from_utf8(&cipherbytes).map_err(|e| {
321            Error::EncryptionFailed(format!("{e} (encrypting {login_id}: data not utf8)"))
322        })?;
323        Ok(ciphertext.to_owned())
324    }
325
326    pub fn decrypt(
327        ciphertext: &str,
328        encdec: &dyn EncryptorDecryptor,
329        login_id: &str,
330    ) -> Result<Self> {
331        let jsonbytes = encdec.decrypt(ciphertext.as_bytes().into()).map_err(|e| {
332            Error::DecryptionFailed(format!(
333                "{e} (decrypting {login_id}, ciphertext length: {})",
334                ciphertext.len(),
335            ))
336        })?;
337        let json =
338            std::str::from_utf8(&jsonbytes).map_err(|e| Error::DecryptionFailed(e.to_string()))?;
339        Ok(serde_json::from_str(json)?)
340    }
341}
342
343/// Login data specific to database records
344#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
345pub struct LoginMeta {
346    pub id: String,
347    pub time_created: i64,
348    pub time_password_changed: i64,
349    pub time_last_used: i64,
350    pub times_used: i64,
351    pub time_last_breach_alert_dismissed: Option<i64>,
352}
353
354/// A login together with meta fields, handed over to the store API; ie a login persisted
355/// elsewhere, useful for migrations
356pub struct LoginEntryWithMeta {
357    pub entry: LoginEntry,
358    pub meta: LoginMeta,
359}
360
361/// A bulk insert result entry, returned by `add_many` and `add_many_with_records`
362/// Please note that although the success case is much larger than the error case, this is
363/// negligible in real life, as we expect a very small success/error ratio.
364#[allow(clippy::large_enum_variant)]
365pub enum BulkResultEntry {
366    Success { login: Login },
367    Error { message: String },
368}
369
370/// A login handed over to the store API; ie a login not yet persisted
371#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
372pub struct LoginEntry {
373    // login fields
374    pub origin: String,
375    pub form_action_origin: Option<String>,
376    pub http_realm: Option<String>,
377    pub username_field: String,
378    pub password_field: String,
379
380    // secure fields
381    pub username: String,
382    pub password: String,
383}
384
385#[cfg(feature = "perform_additional_origin_fixups")]
386mod origin_fixup {
387    fn looks_like_bare_ipv4(s: &str) -> bool {
388        let parts: Vec<&str> = s.split('.').collect();
389        parts.len() == 4 && parts.iter().all(|p| p.parse::<u8>().is_ok())
390    }
391
392    // Returns true if `s` looks like a bare domain name (e.g. `example.com`):
393    // at least two dot-separated labels, each label only ASCII alphanumeric or hyphens.
394    fn looks_like_bare_domain(s: &str) -> bool {
395        let parts: Vec<&str> = s.split('.').collect();
396        parts.len() >= 2
397            && parts
398                .iter()
399                .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
400    }
401
402    // Returns true if `s` looks like a single hostname label (no dots),
403    // e.g. addon-generated origins like "example".
404    fn looks_like_bare_label(s: &str) -> bool {
405        !s.is_empty()
406            && !s.contains('.')
407            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
408    }
409
410    // Attempts to repair origins that fail URL parsing:
411    // - bare https: / https:/ / https:// → https://moz.pwmngr.fixed
412    // - http://ftp.<IPv4>[:port] → ftp://<IPv4>[:port]  (FireFTP quirk)
413    // - ftp.<IPv4>[:port] without a scheme → ftp://<IPv4>[:port]
414    // - ftp.<domain> without a scheme → ftp://ftp.<domain>
415    // - bare IPv4 address or bare domain → moz-pwmngr-fixed://<host>
416    // - bare label (e.g. example) → moz-pwmngr-fixed://<label>
417    pub fn perform_additional_origin_fixup(origin: &str) -> Option<String> {
418        // Bare https: with missing or incomplete authority.
419        if matches!(origin, "https:" | "https:/" | "https://") {
420            return Some("https://moz.pwmngr.fixed".to_string());
421        }
422
423        // http://ftp.<IP>[:port] → ftp://<IP>[:port]
424        if let Some(rest) = origin.strip_prefix("http://ftp.") {
425            let host = rest.split(':').next().unwrap_or(rest);
426            if looks_like_bare_ipv4(host) {
427                return Some(format!("ftp://{rest}"));
428            }
429        }
430
431        // ftp.<IPv4 or bare domain> without a scheme
432        if let Some(rest) = origin.strip_prefix("ftp.") {
433            if looks_like_bare_ipv4(rest) {
434                // ftp.<IP> → ftp://<IP> (strips ftp. prefix; ftp://ftp.<IP> would fail URL parsing)
435                return Some(format!("ftp://{rest}"));
436            } else if looks_like_bare_domain(rest) {
437                // ftp.<domain> → ftp://ftp.<domain>
438                return Some(format!("ftp://{origin}"));
439            }
440        }
441
442        // bare domain, IPv4 address, or single-label hostname → moz-pwmngr-fixed://
443        if looks_like_bare_domain(origin) || looks_like_bare_label(origin) {
444            return Some(format!("moz-pwmngr-fixed://{origin}"));
445        }
446
447        None
448    }
449}
450
451impl LoginEntry {
452    pub fn new(fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
453        Self {
454            origin: fields.origin,
455            form_action_origin: fields.form_action_origin,
456            http_realm: fields.http_realm,
457            username_field: fields.username_field,
458            password_field: fields.password_field,
459
460            username: sec_fields.username,
461            password: sec_fields.password,
462        }
463    }
464
465    /// Shared core logic for origin-like fields: parses `origin` as a URL and
466    /// normalizes it to origin-only form. Returns `Ok(None)` if the input is
467    /// already a valid, normalized origin, `Ok(Some(fixed))` if it needed
468    /// normalization, or `Err` if the input cannot be parsed as a URL.
469    fn parse_and_normalize_origin(origin: &str) -> Result<Option<String>> {
470        match Url::parse(origin) {
471            Ok(mut u) => {
472                // Presumably this is a faster path than always setting?
473                if u.path() != "/"
474                    || u.fragment().is_some()
475                    || u.query().is_some()
476                    || u.username() != "/"
477                    || u.password().is_some()
478                {
479                    // Not identical - we only want the origin part, so kill
480                    // any other parts which may exist.
481                    // But first special case `file://` URLs which always
482                    // resolve to `file://`
483                    if u.scheme() == "file" {
484                        return Ok(if origin == "file://" {
485                            None
486                        } else {
487                            Some("file://".into())
488                        });
489                    }
490                    u.set_path("");
491                    u.set_fragment(None);
492                    u.set_query(None);
493                    let _ = u.set_username("");
494                    let _ = u.set_password(None);
495                    let mut href = String::from(u);
496                    // We always store without the trailing "/" which Urls have.
497                    if href.ends_with('/') {
498                        href.pop().expect("url must have a length");
499                    }
500                    if origin != href {
501                        // Needs to be fixed up.
502                        return Ok(Some(href));
503                    }
504                }
505                Ok(None)
506            }
507            Err(e) => {
508                breadcrumb!(
509                    "Error parsing login origin: {e:?} ({})",
510                    error_support::redact_url(origin)
511                );
512                Err(InvalidLogin::IllegalOrigin {
513                    reason: e.to_string(),
514                }
515                .into())
516            }
517        }
518    }
519
520    /// Validation and fixups for a login `origin`.
521    ///
522    /// When the `perform_additional_origin_fixups` feature is enabled, some
523    /// origins that fail URL parsing (bare domains, FireFTP quirks, etc.)
524    /// are repaired into parseable URLs.
525    pub fn validate_and_fixup_origin(origin: &str) -> Result<Option<String>> {
526        match Self::parse_and_normalize_origin(origin) {
527            Ok(result) => Ok(result),
528            Err(e) => {
529                #[cfg(feature = "perform_additional_origin_fixups")]
530                if let Some(fixed) = origin_fixup::perform_additional_origin_fixup(origin) {
531                    if Url::parse(&fixed).is_ok() {
532                        return Ok(Some(fixed));
533                    }
534                }
535                Err(e)
536            }
537        }
538    }
539
540    /// Validation and normalizations for a login `form_action_origin`.
541    ///
542    /// When the `ignore_form_action_origin_validation_errors` feature is
543    /// enabled, unparseable values are accepted as-is (returning `Ok(None)`
544    /// so callers keep the original string), allowing non-URL values such
545    /// as "email" or "UserCode" that exist in some Desktop databases to be
546    /// saved regardless.
547    pub fn validate_and_normalize_form_action_origin(
548        form_action_origin: &str,
549    ) -> Result<Option<String>> {
550        match Self::parse_and_normalize_origin(form_action_origin) {
551            Ok(result) => Ok(result),
552            #[cfg(feature = "ignore_form_action_origin_validation_errors")]
553            Err(_) => Ok(None),
554            #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
555            Err(e) => Err(e),
556        }
557    }
558}
559
560/// A login handed over from the store API, which has been persisted and contains persistence
561/// information such as id and time stamps
562#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
563pub struct Login {
564    // meta fields
565    pub id: String,
566    pub time_created: i64,
567    pub time_password_changed: i64,
568    pub time_last_used: i64,
569    pub times_used: i64,
570    // breach alerts
571    pub time_last_breach_alert_dismissed: Option<i64>,
572
573    // login fields
574    pub origin: String,
575    pub form_action_origin: Option<String>,
576    pub http_realm: Option<String>,
577    pub username_field: String,
578    pub password_field: String,
579
580    // secure fields
581    pub username: String,
582    pub password: String,
583}
584
585impl Login {
586    pub fn new(meta: LoginMeta, fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
587        Self {
588            id: meta.id,
589            time_created: meta.time_created,
590            time_password_changed: meta.time_password_changed,
591            time_last_used: meta.time_last_used,
592            times_used: meta.times_used,
593            time_last_breach_alert_dismissed: meta.time_last_breach_alert_dismissed,
594
595            origin: fields.origin,
596            form_action_origin: fields.form_action_origin,
597            http_realm: fields.http_realm,
598            username_field: fields.username_field,
599            password_field: fields.password_field,
600
601            username: sec_fields.username,
602            password: sec_fields.password,
603        }
604    }
605
606    #[inline]
607    pub fn guid(&self) -> Guid {
608        Guid::from_string(self.id.clone())
609    }
610
611    pub fn entry(&self) -> LoginEntry {
612        LoginEntry {
613            origin: self.origin.clone(),
614            form_action_origin: self.form_action_origin.clone(),
615            http_realm: self.http_realm.clone(),
616            username_field: self.username_field.clone(),
617            password_field: self.password_field.clone(),
618
619            username: self.username.clone(),
620            password: self.password.clone(),
621        }
622    }
623
624    pub fn encrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<EncryptedLogin> {
625        let sec_fields = SecureLoginFields {
626            username: self.username,
627            password: self.password,
628        }
629        .encrypt(encdec, &self.id)?;
630        Ok(EncryptedLogin {
631            meta: LoginMeta {
632                id: self.id,
633                time_created: self.time_created,
634                time_password_changed: self.time_password_changed,
635                time_last_used: self.time_last_used,
636                times_used: self.times_used,
637                time_last_breach_alert_dismissed: self.time_last_breach_alert_dismissed,
638            },
639            fields: LoginFields {
640                origin: self.origin,
641                form_action_origin: self.form_action_origin,
642                http_realm: self.http_realm,
643                username_field: self.username_field,
644                password_field: self.password_field,
645            },
646            sec_fields,
647        })
648    }
649}
650
651/// A login stored in the database
652#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
653pub struct EncryptedLogin {
654    pub meta: LoginMeta,
655    pub fields: LoginFields,
656    pub sec_fields: String,
657}
658
659impl EncryptedLogin {
660    #[inline]
661    pub fn guid(&self) -> Guid {
662        Guid::from_string(self.meta.id.clone())
663    }
664
665    // TODO: Remove this: https://github.com/mozilla/application-services/issues/4185
666    #[inline]
667    pub fn guid_str(&self) -> &str {
668        &self.meta.id
669    }
670
671    pub fn decrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<Login> {
672        let sec_fields = self.decrypt_fields(encdec)?;
673        Ok(Login::new(self.meta, self.fields, sec_fields))
674    }
675
676    pub fn decrypt_fields(&self, encdec: &dyn EncryptorDecryptor) -> Result<SecureLoginFields> {
677        SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)
678    }
679
680    pub(crate) fn from_row(row: &Row<'_>) -> Result<EncryptedLogin> {
681        let login = EncryptedLogin {
682            meta: LoginMeta {
683                id: row.get("guid")?,
684                time_created: row.get("timeCreated")?,
685                // Might be null
686                time_last_used: row
687                    .get::<_, Option<i64>>("timeLastUsed")?
688                    .unwrap_or_default(),
689
690                time_password_changed: row.get("timePasswordChanged")?,
691                times_used: row.get("timesUsed")?,
692
693                time_last_breach_alert_dismissed: row
694                    .get::<_, Option<i64>>("timeLastBreachAlertDismissed")?,
695            },
696            fields: LoginFields {
697                origin: row.get("origin")?,
698                http_realm: row.get("httpRealm")?,
699
700                form_action_origin: row.get("formActionOrigin")?,
701
702                username_field: string_or_default(row, "usernameField")?,
703                password_field: string_or_default(row, "passwordField")?,
704            },
705            sec_fields: row.get("secFields")?,
706        };
707        // XXX - we used to perform a fixup here, but that seems heavy-handed
708        // and difficult - we now only do that on add/insert when we have the
709        // encryption key.
710        Ok(login)
711    }
712}
713
714fn string_or_default(row: &Row<'_>, col: &str) -> Result<String> {
715    Ok(row.get::<_, Option<String>>(col)?.unwrap_or_default())
716}
717
718pub trait ValidateAndFixup {
719    // Our validate and fixup functions.
720    fn check_valid(&self) -> Result<()>
721    where
722        Self: Sized,
723    {
724        self.validate_and_fixup(false)?;
725        Ok(())
726    }
727
728    fn fixup(self) -> Result<Self>
729    where
730        Self: Sized,
731    {
732        match self.maybe_fixup()? {
733            None => Ok(self),
734            Some(login) => Ok(login),
735        }
736    }
737
738    fn maybe_fixup(&self) -> Result<Option<Self>>
739    where
740        Self: Sized,
741    {
742        self.validate_and_fixup(true)
743    }
744
745    // validates, and optionally fixes, a struct. If fixup is false and there is a validation
746    // issue, an `Err` is returned. If fixup is true and a problem was fixed, and `Ok(Some<Self>)`
747    // is returned with the fixed version. If there was no validation problem, `Ok(None)` is
748    // returned.
749    fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>>
750    where
751        Self: Sized;
752}
753
754impl ValidateAndFixup for LoginEntry {
755    fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>> {
756        // XXX TODO: we've definitely got more validation and fixups to add here!
757
758        let mut maybe_fixed = None;
759
760        /// A little helper to magic a Some(self.clone()) into existence when needed.
761        macro_rules! get_fixed_or_throw {
762            ($err:expr) => {
763                // This is a block expression returning a local variable,
764                // entirely so we can give it an explicit type declaration.
765                {
766                    if !fixup {
767                        return Err($err.into());
768                    }
769                    warn!("Fixing login record {:?}", $err);
770                    let fixed: Result<&mut Self> =
771                        Ok(maybe_fixed.get_or_insert_with(|| self.clone()));
772                    fixed
773                }
774            };
775        }
776
777        if self.origin.is_empty() {
778            return Err(InvalidLogin::EmptyOrigin.into());
779        }
780
781        if self.form_action_origin.is_some() && self.http_realm.is_some() {
782            get_fixed_or_throw!(InvalidLogin::BothTargets)?.http_realm = None;
783        }
784
785        if self.form_action_origin.is_none() && self.http_realm.is_none() {
786            return Err(InvalidLogin::NoTarget.into());
787        }
788
789        let form_action_origin = self.form_action_origin.clone().unwrap_or_default();
790        let http_realm = maybe_fixed
791            .as_ref()
792            .unwrap_or(self)
793            .http_realm
794            .clone()
795            .unwrap_or_default();
796
797        let field_data = [
798            ("form_action_origin", &form_action_origin),
799            ("http_realm", &http_realm),
800            ("origin", &self.origin),
801            ("username_field", &self.username_field),
802            ("password_field", &self.password_field),
803        ];
804
805        for (field_name, field_value) in &field_data {
806            // Nuls are invalid.
807            if field_value.contains('\0') {
808                return Err(InvalidLogin::IllegalFieldValue {
809                    field_info: format!("`{}` contains Nul", field_name),
810                }
811                .into());
812            }
813
814            // Newlines are invalid in Desktop for all the fields here.
815            if field_value.contains('\n') || field_value.contains('\r') {
816                return Err(InvalidLogin::IllegalFieldValue {
817                    field_info: format!("`{}` contains newline", field_name),
818                }
819                .into());
820            }
821        }
822
823        // Desktop doesn't like fields with the below patterns
824        if self.username_field == "." {
825            return Err(InvalidLogin::IllegalFieldValue {
826                field_info: "`username_field` is a period".into(),
827            }
828            .into());
829        }
830
831        // Check we can parse the origin, then use the normalized version of it.
832        if let Some(fixed) = Self::validate_and_fixup_origin(&self.origin)? {
833            get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
834                field_info: "Origin is not normalized".into()
835            })?
836            .origin = fixed;
837        }
838
839        match &maybe_fixed.as_ref().unwrap_or(self).form_action_origin {
840            None => {
841                if !self.username_field.is_empty() {
842                    get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
843                        field_info: "username_field must be empty when form_action_origin is null"
844                            .into()
845                    })?
846                    .username_field
847                    .clear();
848                }
849                if !self.password_field.is_empty() {
850                    get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
851                        field_info: "password_field must be empty when form_action_origin is null"
852                            .into()
853                    })?
854                    .password_field
855                    .clear();
856                }
857            }
858            Some(href) => {
859                // "", ".", and "javascript:" are special cases documented at the top of this file.
860                if href == "." {
861                    // A bit of a special case - if we are being asked to fixup, we replace
862                    // "." with an empty string - but if not fixing up we don't complain.
863                    if fixup {
864                        maybe_fixed
865                            .get_or_insert_with(|| self.clone())
866                            .form_action_origin = Some("".into());
867                    }
868                } else if !href.is_empty() && href != "javascript:" {
869                    match Self::validate_and_normalize_form_action_origin(href) {
870                        Ok(Some(fixed)) => {
871                            get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
872                                field_info: "form_action_origin is not normalized".into()
873                            })?
874                            .form_action_origin = Some(fixed);
875                        }
876                        Ok(None) => {}
877                        Err(e) => return Err(e),
878                    }
879                }
880            }
881        }
882
883        // secure fields
884        //
885        // \r\n chars are valid in desktop for some reason, so we allow them here too.
886        if self.username.contains('\0') {
887            return Err(InvalidLogin::IllegalFieldValue {
888                field_info: "`username` contains Nul".into(),
889            }
890            .into());
891        }
892        // The `allow_empty_passwords` feature flag is used on desktop during the migration phase
893        // to allow existing logins with empty passwords to be imported.
894        #[cfg(not(feature = "allow_empty_passwords"))]
895        if self.password.is_empty() {
896            return Err(InvalidLogin::EmptyPassword.into());
897        }
898        if self.password.contains('\0') {
899            return Err(InvalidLogin::IllegalFieldValue {
900                field_info: "`password` contains Nul".into(),
901            }
902            .into());
903        }
904
905        Ok(maybe_fixed)
906    }
907}
908
909#[cfg(test)]
910pub mod test_utils {
911    use super::*;
912    use crate::encryption::test_utils::encrypt_struct;
913
914    // Factory function to make a new login
915    //
916    // It uses the guid to create a unique origin/form_action_origin
917    pub fn enc_login(id: &str, password: &str) -> EncryptedLogin {
918        let sec_fields = SecureLoginFields {
919            username: "user".to_string(),
920            password: password.to_string(),
921        };
922        EncryptedLogin {
923            meta: LoginMeta {
924                id: id.to_string(),
925                ..Default::default()
926            },
927            fields: LoginFields {
928                form_action_origin: Some(format!("https://{}.example.com", id)),
929                origin: format!("https://{}.example.com", id),
930                ..Default::default()
931            },
932            // TODO: fixme
933            sec_fields: encrypt_struct(&sec_fields),
934        }
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    #[test]
943    fn test_url_fixups() -> Result<()> {
944        // Start with URLs which are all valid and already normalized.
945        for input in &[
946            // The list of valid origins documented at the top of this file.
947            "https://site.com",
948            "http://site.com:1234",
949            "ftp://ftp.site.com",
950            "moz-proxy://127.0.0.1:8888",
951            "chrome://MyLegacyExtension",
952            "file://",
953            "https://[::1]",
954        ] {
955            assert_eq!(LoginEntry::validate_and_fixup_origin(input)?, None);
956        }
957
958        // And URLs which get normalized.
959        for (input, output) in &[
960            ("https://site.com/", "https://site.com"),
961            ("http://site.com:1234/", "http://site.com:1234"),
962            ("http://example.com/foo?query=wtf#bar", "http://example.com"),
963            ("http://example.com/foo#bar", "http://example.com"),
964            (
965                "http://username:password@example.com/",
966                "http://example.com",
967            ),
968            ("http://😍.com/", "http://xn--r28h.com"),
969            ("https://[0:0:0:0:0:0:0:1]", "https://[::1]"),
970            // All `file://` URLs normalize to exactly `file://`. See #2384 for
971            // why we might consider changing that later.
972            ("file:///", "file://"),
973            ("file://foo/bar", "file://"),
974            ("file://foo/bar/", "file://"),
975            ("moz-proxy://127.0.0.1:8888/", "moz-proxy://127.0.0.1:8888"),
976            (
977                "moz-proxy://127.0.0.1:8888/foo",
978                "moz-proxy://127.0.0.1:8888",
979            ),
980            ("chrome://MyLegacyExtension/", "chrome://MyLegacyExtension"),
981            (
982                "chrome://MyLegacyExtension/foo",
983                "chrome://MyLegacyExtension",
984            ),
985        ] {
986            assert_eq!(
987                LoginEntry::validate_and_fixup_origin(input)?,
988                Some((*output).into())
989            );
990        }
991
992        // Finally, look at some invalid logins
993        {
994            let input = &".";
995            assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
996        }
997        // With perform_additional_origin_fixups, bare domains/labels get a moz-pwmngr-fixed:// scheme
998        #[cfg(not(feature = "perform_additional_origin_fixups"))]
999        for input in &["example.com", "example"] {
1000            assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
1001        }
1002        #[cfg(feature = "perform_additional_origin_fixups")]
1003        {
1004            assert_eq!(
1005                LoginEntry::validate_and_fixup_origin("example.com")?,
1006                Some("moz-pwmngr-fixed://example.com".into())
1007            );
1008            assert_eq!(
1009                LoginEntry::validate_and_fixup_origin("example")?,
1010                Some("moz-pwmngr-fixed://example".into())
1011            );
1012        }
1013
1014        Ok(())
1015    }
1016
1017    #[cfg(feature = "perform_additional_origin_fixups")]
1018    #[test]
1019    fn test_additional_origin_fixups() -> Result<()> {
1020        // Origins that are already valid should not be changed
1021        for input in &[
1022            "https://example.com",
1023            "http://example.com:8080",
1024            "ftp://ftp.example.com",
1025            "moz-pwmngr-fixed://example.com",
1026            "moz-pwmngr-fixed://foo.bar",
1027        ] {
1028            assert_eq!(
1029                LoginEntry::validate_and_fixup_origin(input)?,
1030                None,
1031                "expected no change for: {input}"
1032            );
1033        }
1034
1035        // bare https: with incomplete authority (e.g. corrupted or addon-generated entry)
1036        for input in &["https:", "https:/", "https://"] {
1037            assert_eq!(
1038                LoginEntry::validate_and_fixup_origin(input)?,
1039                Some("https://moz.pwmngr.fixed".into()),
1040                "input: {input}"
1041            );
1042        }
1043
1044        // http://ftp.<IP>[:port] — FireFTP stored origins like this instead of ftp://
1045        assert_eq!(
1046            LoginEntry::validate_and_fixup_origin("http://ftp.1.2.3.4")?,
1047            Some("ftp://1.2.3.4".into())
1048        );
1049        assert_eq!(
1050            LoginEntry::validate_and_fixup_origin("http://ftp.1.2.3.4:21")?,
1051            Some("ftp://1.2.3.4:21".into())
1052        );
1053
1054        // ftp.<IPv4> without a scheme — FireFTP IP variant (ftp. prefix stripped;
1055        // ftp://ftp.<IP> would fail URL parsing due to the url crate's IPv4 detection)
1056        assert_eq!(
1057            LoginEntry::validate_and_fixup_origin("ftp.1.2.3.4")?,
1058            Some("ftp://1.2.3.4".into())
1059        );
1060        // ftp.<domain> without a scheme — FireFTP domain variant
1061        assert_eq!(
1062            LoginEntry::validate_and_fixup_origin("ftp.example.com")?,
1063            Some("ftp://ftp.example.com".into())
1064        );
1065
1066        // bare IPv4 address — addon-generated or manually entered
1067        assert_eq!(
1068            LoginEntry::validate_and_fixup_origin("1.2.3.4")?,
1069            Some("moz-pwmngr-fixed://1.2.3.4".into())
1070        );
1071
1072        // bare domain without a scheme — addon-generated origins (e.g. PassHash, gManager)
1073        for (input, output) in &[
1074            ("example.com", "moz-pwmngr-fixed://example.com"),
1075            ("sub.example.com", "moz-pwmngr-fixed://sub.example.com"),
1076            ("foo.bar", "moz-pwmngr-fixed://foo.bar"),
1077        ] {
1078            assert_eq!(
1079                LoginEntry::validate_and_fixup_origin(input)?,
1080                Some((*output).into()),
1081                "input: {input}"
1082            );
1083        }
1084
1085        // bare single-label hostname — addon-generated origins
1086        assert_eq!(
1087            LoginEntry::validate_and_fixup_origin("example")?,
1088            Some("moz-pwmngr-fixed://example".into())
1089        );
1090
1091        // things that cannot be fixed even with the feature on
1092        assert!(LoginEntry::validate_and_fixup_origin(".").is_err());
1093
1094        Ok(())
1095    }
1096
1097    #[test]
1098    fn test_form_action_origin_normalizes_valid_urls() -> Result<()> {
1099        // Already-normalized origins pass through.
1100        assert_eq!(
1101            LoginEntry::validate_and_normalize_form_action_origin("https://example.com")?,
1102            None
1103        );
1104        // Full URLs get normalized to origin-only form, same as for `origin`.
1105        assert_eq!(
1106            LoginEntry::validate_and_normalize_form_action_origin("https://example.com/foo?x=1")?,
1107            Some("https://example.com".into())
1108        );
1109        Ok(())
1110    }
1111
1112    // The `perform_additional_origin_fixups` feature is intentionally scoped
1113    // to the `origin` field. Inputs that it would repair for `origin` must
1114    // NOT be repaired here.
1115    #[cfg(feature = "perform_additional_origin_fixups")]
1116    #[test]
1117    fn test_form_action_origin_skips_additional_fixups() {
1118        for input in &[
1119            "example.com",
1120            "example",
1121            "1.2.3.4",
1122            "https:",
1123            "ftp.example.com",
1124        ] {
1125            let result = LoginEntry::validate_and_normalize_form_action_origin(input);
1126            // The result depends on the other feature flag, but in no case
1127            // should it be the moz-pwmngr-fixed:// / repaired form returned
1128            // by `validate_and_fixup_origin`.
1129            #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1130            assert_eq!(result.unwrap(), None, "input: {input}");
1131            #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
1132            assert!(result.is_err(), "input: {input}");
1133        }
1134    }
1135
1136    #[test]
1137    #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
1138    fn test_form_action_origin_rejects_invalid() {
1139        assert!(LoginEntry::validate_and_normalize_form_action_origin("email").is_err());
1140    }
1141
1142    #[test]
1143    #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1144    fn test_form_action_origin_accepts_invalid_with_feature() {
1145        // With the feature on, unparseable values return Ok(None) — meaning
1146        // "no fixup needed", so callers keep the original string as-is.
1147        assert_eq!(
1148            LoginEntry::validate_and_normalize_form_action_origin("email").unwrap(),
1149            None
1150        );
1151    }
1152
1153    #[test]
1154    fn test_check_valid() {
1155        #[derive(Debug, Clone)]
1156        struct TestCase {
1157            login: LoginEntry,
1158            should_err: bool,
1159            expected_err: &'static str,
1160        }
1161
1162        let valid_login = LoginEntry {
1163            origin: "https://www.example.com".into(),
1164            http_realm: Some("https://www.example.com".into()),
1165            username: "test".into(),
1166            password: "test".into(),
1167            ..Default::default()
1168        };
1169
1170        let login_with_empty_origin = LoginEntry {
1171            origin: "".into(),
1172            http_realm: Some("https://www.example.com".into()),
1173            username: "test".into(),
1174            password: "test".into(),
1175            ..Default::default()
1176        };
1177
1178        let login_with_empty_password = LoginEntry {
1179            origin: "https://www.example.com".into(),
1180            http_realm: Some("https://www.example.com".into()),
1181            username: "test".into(),
1182            password: "".into(),
1183            ..Default::default()
1184        };
1185
1186        let login_with_form_submit_and_http_realm = LoginEntry {
1187            origin: "https://www.example.com".into(),
1188            http_realm: Some("https://www.example.com".into()),
1189            form_action_origin: Some("https://www.example.com".into()),
1190            username: "".into(),
1191            password: "test".into(),
1192            ..Default::default()
1193        };
1194
1195        let login_without_form_submit_or_http_realm = LoginEntry {
1196            origin: "https://www.example.com".into(),
1197            username: "".into(),
1198            password: "test".into(),
1199            ..Default::default()
1200        };
1201
1202        let login_with_legacy_form_submit_and_http_realm = LoginEntry {
1203            origin: "https://www.example.com".into(),
1204            form_action_origin: Some("".into()),
1205            username: "".into(),
1206            password: "test".into(),
1207            ..Default::default()
1208        };
1209
1210        let login_with_null_http_realm = LoginEntry {
1211            origin: "https://www.example.com".into(),
1212            http_realm: Some("https://www.example.\0com".into()),
1213            username: "test".into(),
1214            password: "test".into(),
1215            ..Default::default()
1216        };
1217
1218        let login_with_null_username = LoginEntry {
1219            origin: "https://www.example.com".into(),
1220            http_realm: Some("https://www.example.com".into()),
1221            username: "\0".into(),
1222            password: "test".into(),
1223            ..Default::default()
1224        };
1225
1226        let login_with_null_password = LoginEntry {
1227            origin: "https://www.example.com".into(),
1228            http_realm: Some("https://www.example.com".into()),
1229            username: "username".into(),
1230            password: "test\0".into(),
1231            ..Default::default()
1232        };
1233
1234        let login_with_newline_origin = LoginEntry {
1235            origin: "\rhttps://www.example.com".into(),
1236            http_realm: Some("https://www.example.com".into()),
1237            username: "test".into(),
1238            password: "test".into(),
1239            ..Default::default()
1240        };
1241
1242        let login_with_newline_username_field = LoginEntry {
1243            origin: "https://www.example.com".into(),
1244            http_realm: Some("https://www.example.com".into()),
1245            username_field: "\n".into(),
1246            username: "test".into(),
1247            password: "test".into(),
1248            ..Default::default()
1249        };
1250
1251        let login_with_newline_realm = LoginEntry {
1252            origin: "https://www.example.com".into(),
1253            http_realm: Some("foo\nbar".into()),
1254            username: "test".into(),
1255            password: "test".into(),
1256            ..Default::default()
1257        };
1258
1259        let login_with_newline_password = LoginEntry {
1260            origin: "https://www.example.com".into(),
1261            http_realm: Some("https://www.example.com".into()),
1262            username: "test".into(),
1263            password: "test\n".into(),
1264            ..Default::default()
1265        };
1266
1267        let login_with_period_username_field = LoginEntry {
1268            origin: "https://www.example.com".into(),
1269            http_realm: Some("https://www.example.com".into()),
1270            username_field: ".".into(),
1271            username: "test".into(),
1272            password: "test".into(),
1273            ..Default::default()
1274        };
1275
1276        let login_with_period_form_action_origin = LoginEntry {
1277            form_action_origin: Some(".".into()),
1278            origin: "https://www.example.com".into(),
1279            username: "test".into(),
1280            password: "test".into(),
1281            ..Default::default()
1282        };
1283
1284        let login_with_javascript_form_action_origin = LoginEntry {
1285            form_action_origin: Some("javascript:".into()),
1286            origin: "https://www.example.com".into(),
1287            username: "test".into(),
1288            password: "test".into(),
1289            ..Default::default()
1290        };
1291
1292        let login_with_malformed_origin_parens = LoginEntry {
1293            origin: " (".into(),
1294            http_realm: Some("https://www.example.com".into()),
1295            username: "test".into(),
1296            password: "test".into(),
1297            ..Default::default()
1298        };
1299
1300        let login_with_host_unicode = LoginEntry {
1301            origin: "http://💖.com".into(),
1302            http_realm: Some("https://www.example.com".into()),
1303            username: "test".into(),
1304            password: "test".into(),
1305            ..Default::default()
1306        };
1307
1308        let login_with_origin_trailing_slash = LoginEntry {
1309            origin: "https://www.example.com/".into(),
1310            http_realm: Some("https://www.example.com".into()),
1311            username: "test".into(),
1312            password: "test".into(),
1313            ..Default::default()
1314        };
1315
1316        let login_with_origin_expanded_ipv6 = LoginEntry {
1317            origin: "https://[0:0:0:0:0:0:1:1]".into(),
1318            http_realm: Some("https://www.example.com".into()),
1319            username: "test".into(),
1320            password: "test".into(),
1321            ..Default::default()
1322        };
1323
1324        let login_with_unknown_protocol = LoginEntry {
1325            origin: "moz-proxy://127.0.0.1:8888".into(),
1326            http_realm: Some("https://www.example.com".into()),
1327            username: "test".into(),
1328            password: "test".into(),
1329            ..Default::default()
1330        };
1331
1332        let test_cases = [
1333            TestCase {
1334                login: valid_login,
1335                should_err: false,
1336                expected_err: "",
1337            },
1338            TestCase {
1339                login: login_with_empty_origin,
1340                should_err: true,
1341                expected_err: "Invalid login: Origin is empty",
1342            },
1343            TestCase {
1344                login: login_with_empty_password,
1345                should_err: cfg!(not(feature = "allow_empty_passwords")),
1346                expected_err: "Invalid login: Password is empty",
1347            },
1348            TestCase {
1349                login: login_with_form_submit_and_http_realm,
1350                should_err: true,
1351                expected_err: "Invalid login: Both `formActionOrigin` and `httpRealm` are present",
1352            },
1353            TestCase {
1354                login: login_without_form_submit_or_http_realm,
1355                should_err: true,
1356                expected_err:
1357                    "Invalid login: Neither `formActionOrigin` or `httpRealm` are present",
1358            },
1359            TestCase {
1360                login: login_with_null_http_realm,
1361                should_err: true,
1362                expected_err: "Invalid login: Login has illegal field: `http_realm` contains Nul",
1363            },
1364            TestCase {
1365                login: login_with_null_username,
1366                should_err: true,
1367                expected_err: "Invalid login: Login has illegal field: `username` contains Nul",
1368            },
1369            TestCase {
1370                login: login_with_null_password,
1371                should_err: true,
1372                expected_err: "Invalid login: Login has illegal field: `password` contains Nul",
1373            },
1374            TestCase {
1375                login: login_with_newline_origin,
1376                should_err: true,
1377                expected_err: "Invalid login: Login has illegal field: `origin` contains newline",
1378            },
1379            TestCase {
1380                login: login_with_newline_realm,
1381                should_err: true,
1382                expected_err:
1383                    "Invalid login: Login has illegal field: `http_realm` contains newline",
1384            },
1385            TestCase {
1386                login: login_with_newline_username_field,
1387                should_err: true,
1388                expected_err:
1389                    "Invalid login: Login has illegal field: `username_field` contains newline",
1390            },
1391            TestCase {
1392                login: login_with_newline_password,
1393                should_err: false,
1394                expected_err: "",
1395            },
1396            TestCase {
1397                login: login_with_period_username_field,
1398                should_err: true,
1399                expected_err:
1400                    "Invalid login: Login has illegal field: `username_field` is a period",
1401            },
1402            TestCase {
1403                login: login_with_period_form_action_origin,
1404                should_err: false,
1405                expected_err: "",
1406            },
1407            TestCase {
1408                login: login_with_javascript_form_action_origin,
1409                should_err: false,
1410                expected_err: "",
1411            },
1412            TestCase {
1413                login: login_with_malformed_origin_parens,
1414                should_err: true,
1415                expected_err:
1416                    "Invalid login: Login has illegal origin: relative URL without a base",
1417            },
1418            TestCase {
1419                login: login_with_host_unicode,
1420                should_err: true,
1421                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1422            },
1423            TestCase {
1424                login: login_with_origin_trailing_slash,
1425                should_err: true,
1426                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1427            },
1428            TestCase {
1429                login: login_with_origin_expanded_ipv6,
1430                should_err: true,
1431                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1432            },
1433            TestCase {
1434                login: login_with_unknown_protocol,
1435                should_err: false,
1436                expected_err: "",
1437            },
1438            TestCase {
1439                login: login_with_legacy_form_submit_and_http_realm,
1440                should_err: false,
1441                expected_err: "",
1442            },
1443        ];
1444
1445        for tc in &test_cases {
1446            let actual = tc.login.check_valid();
1447
1448            if tc.should_err {
1449                assert!(actual.is_err(), "{:#?}", tc);
1450                assert_eq!(
1451                    tc.expected_err,
1452                    actual.unwrap_err().to_string(),
1453                    "{:#?}",
1454                    tc,
1455                );
1456            } else {
1457                assert!(actual.is_ok(), "{:#?}", tc);
1458                assert!(
1459                    tc.login.clone().fixup().is_ok(),
1460                    "Fixup failed after check_valid passed: {:#?}",
1461                    &tc,
1462                );
1463            }
1464        }
1465    }
1466
1467    #[test]
1468    fn test_fixup() {
1469        #[derive(Debug, Default)]
1470        struct TestCase {
1471            login: LoginEntry,
1472            fixedup_host: Option<&'static str>,
1473            fixedup_form_action_origin: Option<String>,
1474        }
1475
1476        // Note that most URL fixups are tested above, but we have one or 2 here.
1477        let login_with_full_url = LoginEntry {
1478            origin: "http://example.com/foo?query=wtf#bar".into(),
1479            form_action_origin: Some("http://example.com/foo?query=wtf#bar".into()),
1480            username: "test".into(),
1481            password: "test".into(),
1482            ..Default::default()
1483        };
1484
1485        let login_with_host_unicode = LoginEntry {
1486            origin: "http://😍.com".into(),
1487            form_action_origin: Some("http://😍.com".into()),
1488            username: "test".into(),
1489            password: "test".into(),
1490            ..Default::default()
1491        };
1492
1493        let login_with_period_fsu = LoginEntry {
1494            origin: "https://example.com".into(),
1495            form_action_origin: Some(".".into()),
1496            username: "test".into(),
1497            password: "test".into(),
1498            ..Default::default()
1499        };
1500        let login_with_empty_fsu = LoginEntry {
1501            origin: "https://example.com".into(),
1502            form_action_origin: Some("".into()),
1503            username: "test".into(),
1504            password: "test".into(),
1505            ..Default::default()
1506        };
1507
1508        let login_with_form_submit_and_http_realm = LoginEntry {
1509            origin: "https://www.example.com".into(),
1510            form_action_origin: Some("https://www.example.com".into()),
1511            // If both http_realm and form_action_origin are specified, we drop
1512            // the former when fixing up. So for this test we must have an
1513            // invalid value in http_realm to ensure we don't validate a value
1514            // we end up dropping.
1515            http_realm: Some("\n".into()),
1516            username: "".into(),
1517            password: "test".into(),
1518            ..Default::default()
1519        };
1520
1521        let test_cases = [
1522            TestCase {
1523                login: login_with_full_url,
1524                fixedup_host: "http://example.com".into(),
1525                fixedup_form_action_origin: Some("http://example.com".into()),
1526            },
1527            TestCase {
1528                login: login_with_host_unicode,
1529                fixedup_host: "http://xn--r28h.com".into(),
1530                fixedup_form_action_origin: Some("http://xn--r28h.com".into()),
1531            },
1532            TestCase {
1533                login: login_with_period_fsu,
1534                fixedup_form_action_origin: Some("".into()),
1535                ..TestCase::default()
1536            },
1537            TestCase {
1538                login: login_with_form_submit_and_http_realm,
1539                fixedup_form_action_origin: Some("https://www.example.com".into()),
1540                ..TestCase::default()
1541            },
1542            TestCase {
1543                login: login_with_empty_fsu,
1544                // Should still be empty.
1545                fixedup_form_action_origin: Some("".into()),
1546                ..TestCase::default()
1547            },
1548        ];
1549
1550        for tc in &test_cases {
1551            let login = tc.login.clone().fixup().expect("should work");
1552            if let Some(expected) = tc.fixedup_host {
1553                assert_eq!(login.origin, expected, "origin not fixed in {:#?}", tc);
1554            }
1555            assert_eq!(
1556                login.form_action_origin, tc.fixedup_form_action_origin,
1557                "form_action_origin not fixed in {:#?}",
1558                tc,
1559            );
1560            login.check_valid().unwrap_or_else(|e| {
1561                panic!("Fixup produces invalid record: {:#?}", (e, &tc, &login));
1562            });
1563            assert_eq!(
1564                login.clone().fixup().unwrap(),
1565                login,
1566                "fixup did not reach fixed point for testcase: {:#?}",
1567                tc,
1568            );
1569        }
1570    }
1571
1572    #[test]
1573    #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1574    fn test_invalid_form_action_origin_allowed() {
1575        let login = LoginEntry {
1576            origin: "https://example.com".into(),
1577            form_action_origin: Some("email".into()),
1578            username: "test".into(),
1579            password: "test".into(),
1580            ..Default::default()
1581        };
1582        let fixed = login.fixup().expect("should not error");
1583        assert_eq!(fixed.form_action_origin, Some("email".into()));
1584    }
1585
1586    #[test]
1587    fn test_secure_fields_serde() {
1588        let sf = SecureLoginFields {
1589            username: "foo".into(),
1590            password: "pwd".into(),
1591        };
1592        assert_eq!(
1593            serde_json::to_string(&sf).unwrap(),
1594            r#"{"u":"foo","p":"pwd"}"#
1595        );
1596        let got: SecureLoginFields = serde_json::from_str(r#"{"u": "user", "p": "p"}"#).unwrap();
1597        let expected = SecureLoginFields {
1598            username: "user".into(),
1599            password: "p".into(),
1600        };
1601        assert_eq!(got, expected);
1602    }
1603}