use crate::error::*;
use crate::msg_types::PasswordInfo;
use crate::util;
use rusqlite::Row;
use serde_derive::*;
use std::time::{self, SystemTime};
use sync15::ServerTimestamp;
use sync_guid::Guid;
use url::Url;
#[derive(Debug, Clone, Hash, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Login {
#[serde(rename = "id")]
pub guid: Guid,
pub hostname: String,
#[serde(rename = "formSubmitURL")]
#[serde(skip_serializing_if = "Option::is_none")]
pub form_submit_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_realm: Option<String>,
#[serde(default)]
pub username: String,
pub password: String,
#[serde(default)]
pub username_field: String,
#[serde(default)]
pub password_field: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_timestamp")]
pub time_created: i64,
#[serde(default)]
#[serde(deserialize_with = "deserialize_timestamp")]
pub time_password_changed: i64,
#[serde(default)]
#[serde(deserialize_with = "deserialize_timestamp")]
pub time_last_used: i64,
#[serde(default)]
pub times_used: i64,
}
fn deserialize_timestamp<'de, D>(deserializer: D) -> std::result::Result<i64, D::Error>
where
D: serde::de::Deserializer<'de>,
{
use serde::de::Deserialize;
Ok(i64::deserialize(deserializer).unwrap_or_default().max(0))
}
fn string_or_default(row: &Row<'_>, col: &str) -> Result<String> {
Ok(row.get::<_, Option<String>>(col)?.unwrap_or_default())
}
impl Login {
#[inline]
pub fn guid(&self) -> &Guid {
&self.guid
}
#[inline]
pub fn guid_str(&self) -> &str {
self.guid.as_str()
}
pub fn check_valid(&self) -> Result<()> {
self.validate_and_fixup(false)?;
Ok(())
}
pub fn fixup(self) -> Result<Self> {
match self.maybe_fixup()? {
None => Ok(self),
Some(login) => Ok(login),
}
}
pub fn maybe_fixup(&self) -> Result<Option<Self>> {
self.validate_and_fixup(true)
}
fn validate_and_fixup_origin(origin: &str) -> Result<Option<String>> {
match Url::parse(&origin) {
Ok(mut u) => {
if u.path() != "/"
|| u.fragment().is_some()
|| u.query().is_some()
|| u.username() != "/"
|| u.password().is_some()
{
if u.scheme() == "file" {
return Ok(if origin == "file://" {
None
} else {
Some("file://".into())
});
}
u.set_path("");
u.set_fragment(None);
u.set_query(None);
let _ = u.set_username("");
let _ = u.set_password(None);
let mut href = u.into_string();
if href.ends_with('/') {
href.pop().expect("url must have a length");
}
if origin != href {
return Ok(Some(href));
}
}
Ok(None)
}
Err(_) => {
throw!(InvalidLogin::IllegalFieldValue {
field_info: "Origin is Malformed".into()
});
}
}
}
fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>> {
let mut maybe_fixed = None;
macro_rules! get_fixed_or_throw {
($err:expr) => {
{
if !fixup {
throw!($err)
}
log::warn!("Fixing login record {}: {:?}", self.guid, $err);
let fixed: Result<&mut Login> =
Ok(maybe_fixed.get_or_insert_with(|| self.clone()));
fixed
}
};
};
if self.hostname.is_empty() {
throw!(InvalidLogin::EmptyOrigin);
}
if self.password.is_empty() {
throw!(InvalidLogin::EmptyPassword);
}
if self.form_submit_url.is_some() && self.http_realm.is_some() {
get_fixed_or_throw!(InvalidLogin::BothTargets)?.http_realm = None;
}
if self.form_submit_url.is_none() && self.http_realm.is_none() {
throw!(InvalidLogin::NoTarget);
}
let form_submit_url = self.form_submit_url.clone().unwrap_or_default();
let http_realm = maybe_fixed
.as_ref()
.unwrap_or(self)
.http_realm
.clone()
.unwrap_or_default();
let field_data = [
("formSubmitUrl", &form_submit_url),
("httpRealm", &http_realm),
("hostname", &self.hostname),
("usernameField", &self.username_field),
("passwordField", &self.password_field),
("username", &self.username),
("password", &self.password),
];
for (field_name, field_value) in &field_data {
if field_value.contains('\0') {
throw!(InvalidLogin::IllegalFieldValue {
field_info: format!("`{}` contains Nul", field_name)
});
}
if field_name != &"username"
&& field_name != &"password"
&& (field_value.contains('\n') || field_value.contains('\r'))
{
throw!(InvalidLogin::IllegalFieldValue {
field_info: format!("`{}` contains newline", field_name)
});
}
}
if self.username_field == "." {
throw!(InvalidLogin::IllegalFieldValue {
field_info: "`usernameField` is a period".into()
});
}
if let Some(fixed) = Login::validate_and_fixup_origin(&self.hostname)? {
get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
field_info: "Origin is not normalized".into()
})?
.hostname = fixed;
}
match &maybe_fixed.as_ref().unwrap_or(self).form_submit_url {
None => {
if !self.username_field.is_empty() {
get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
field_info: "usernameField must be empty when formSubmitURL is null".into()
})?
.username_field
.clear();
}
if !self.password_field.is_empty() {
get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
field_info: "passwordField must be empty when formSubmitURL is null".into()
})?
.password_field
.clear();
}
}
Some(href) => {
if href == "." {
if fixup {
maybe_fixed
.get_or_insert_with(|| self.clone())
.form_submit_url = Some("".into());
}
} else if href != "" && href != "javascript:" {
if let Some(fixed) = Login::validate_and_fixup_origin(&href)? {
get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
field_info: "formActionOrigin is not normalized".into()
})?
.form_submit_url = Some(fixed);
}
}
}
}
Ok(maybe_fixed)
}
pub(crate) fn from_row(row: &Row<'_>) -> Result<Login> {
let login = Login {
guid: row.get("guid")?,
password: row.get("password")?,
username: string_or_default(row, "username")?,
hostname: row.get("hostname")?,
http_realm: row.get("httpRealm")?,
form_submit_url: row.get("formSubmitURL")?,
username_field: string_or_default(row, "usernameField")?,
password_field: string_or_default(row, "passwordField")?,
time_created: row.get("timeCreated")?,
time_last_used: row
.get::<_, Option<i64>>("timeLastUsed")?
.unwrap_or_default(),
time_password_changed: row.get("timePasswordChanged")?,
times_used: row.get("timesUsed")?,
};
Ok(login.maybe_fixup().unwrap_or(None).unwrap_or(login))
}
}
impl From<Login> for PasswordInfo {
fn from(login: Login) -> Self {
Self {
id: login.guid.into_string(),
hostname: login.hostname,
password: login.password,
username: login.username,
http_realm: login.http_realm,
form_submit_url: login.form_submit_url,
username_field: login.username_field,
password_field: login.password_field,
times_used: login.times_used,
time_created: login.time_created,
time_last_used: login.time_last_used,
time_password_changed: login.time_password_changed,
}
}
}
impl From<PasswordInfo> for Login {
fn from(info: PasswordInfo) -> Self {
Self {
guid: Guid::from_string(info.id),
hostname: info.hostname,
password: info.password,
username: info.username,
http_realm: info.http_realm,
form_submit_url: info.form_submit_url,
username_field: info.username_field,
password_field: info.password_field,
times_used: info.times_used,
time_created: info.time_created,
time_last_used: info.time_last_used,
time_password_changed: info.time_password_changed,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct MirrorLogin {
pub login: Login,
pub is_overridden: bool,
pub server_modified: ServerTimestamp,
}
impl MirrorLogin {
#[inline]
pub fn guid_str(&self) -> &str {
self.login.guid_str()
}
pub(crate) fn from_row(row: &Row<'_>) -> Result<MirrorLogin> {
Ok(MirrorLogin {
login: Login::from_row(row)?,
is_overridden: row.get("is_overridden")?,
server_modified: ServerTimestamp(row.get::<_, i64>("server_modified")?),
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[repr(u8)]
pub(crate) enum SyncStatus {
Synced = 0,
Changed = 1,
New = 2,
}
impl SyncStatus {
#[inline]
pub fn from_u8(v: u8) -> Result<Self> {
match v {
0 => Ok(SyncStatus::Synced),
1 => Ok(SyncStatus::Changed),
2 => Ok(SyncStatus::New),
v => throw!(ErrorKind::BadSyncStatus(v)),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct LocalLogin {
pub login: Login,
pub sync_status: SyncStatus,
pub is_deleted: bool,
pub local_modified: SystemTime,
}
impl LocalLogin {
#[inline]
pub fn guid_str(&self) -> &str {
self.login.guid_str()
}
pub(crate) fn from_row(row: &Row<'_>) -> Result<LocalLogin> {
Ok(LocalLogin {
login: Login::from_row(row)?,
sync_status: SyncStatus::from_u8(row.get("sync_status")?)?,
is_deleted: row.get("is_deleted")?,
local_modified: util::system_time_millis_from_row(row, "local_modified")?,
})
}
}
macro_rules! impl_login {
($ty:ty { $($fields:tt)* }) => {
impl AsRef<Login> for $ty {
#[inline]
fn as_ref(&self) -> &Login {
&self.login
}
}
impl AsMut<Login> for $ty {
#[inline]
fn as_mut(&mut self) -> &mut Login {
&mut self.login
}
}
impl From<$ty> for Login {
#[inline]
fn from(l: $ty) -> Self {
l.login
}
}
impl From<Login> for $ty {
#[inline]
fn from(login: Login) -> Self {
Self { login, $($fields)* }
}
}
};
}
impl_login!(LocalLogin {
sync_status: SyncStatus::New,
is_deleted: false,
local_modified: time::UNIX_EPOCH
});
impl_login!(MirrorLogin {
is_overridden: false,
server_modified: ServerTimestamp(0)
});
pub(crate) struct SyncLoginData {
pub guid: Guid,
pub local: Option<LocalLogin>,
pub mirror: Option<MirrorLogin>,
pub inbound: (Option<Login>, ServerTimestamp),
}
impl SyncLoginData {
#[inline]
pub fn guid_str(&self) -> &str {
&self.guid.as_str()
}
#[inline]
pub fn guid(&self) -> &Guid {
&self.guid
}
pub fn from_payload(
payload: sync15::Payload,
ts: ServerTimestamp,
) -> std::result::Result<Self, serde_json::Error> {
let guid = payload.id.clone();
let login: Option<Login> = if payload.is_tombstone() {
None
} else {
let record: Login = payload.into_record()?;
record.maybe_fixup().unwrap_or(None).or(Some(record))
};
Ok(Self {
guid,
local: None,
mirror: None,
inbound: (login, ts),
})
}
}
macro_rules! impl_login_setter {
($setter_name:ident, $field:ident, $Login:ty) => {
impl SyncLoginData {
pub(crate) fn $setter_name(&mut self, record: $Login) -> Result<()> {
if self.$field.is_some() {
panic!(
"SyncLoginData::{} called on object that already has {} data",
stringify!($setter_name),
stringify!($field)
);
}
if self.guid_str() != record.guid_str() {
panic!(
"Wrong guid on login in {}: {:?} != {:?}",
stringify!($setter_name),
self.guid_str(),
record.guid_str()
);
}
self.$field = Some(record);
Ok(())
}
}
};
}
impl_login_setter!(set_local, local, LocalLogin);
impl_login_setter!(set_mirror, mirror, MirrorLogin);
#[derive(Debug, Default, Clone)]
pub(crate) struct LoginDelta {
pub hostname: Option<String>,
pub password: Option<String>,
pub username: Option<String>,
pub http_realm: Option<String>,
pub form_submit_url: Option<String>,
pub time_created: Option<i64>,
pub time_last_used: Option<i64>,
pub time_password_changed: Option<i64>,
pub password_field: Option<String>,
pub username_field: Option<String>,
pub times_used: i64,
}
macro_rules! merge_field {
($merged:ident, $b:ident, $prefer_b:expr, $field:ident) => {
if let Some($field) = $b.$field.take() {
if $merged.$field.is_some() {
log::warn!("Collision merging login field {}", stringify!($field));
if $prefer_b {
$merged.$field = Some($field);
}
} else {
$merged.$field = Some($field);
}
}
};
}
impl LoginDelta {
#[allow(clippy::cognitive_complexity)]
pub fn merge(self, mut b: LoginDelta, b_is_newer: bool) -> LoginDelta {
let mut merged = self;
merge_field!(merged, b, b_is_newer, hostname);
merge_field!(merged, b, b_is_newer, password);
merge_field!(merged, b, b_is_newer, username);
merge_field!(merged, b, b_is_newer, http_realm);
merge_field!(merged, b, b_is_newer, form_submit_url);
merge_field!(merged, b, b_is_newer, time_created);
merge_field!(merged, b, b_is_newer, time_last_used);
merge_field!(merged, b, b_is_newer, time_password_changed);
merge_field!(merged, b, b_is_newer, password_field);
merge_field!(merged, b, b_is_newer, username_field);
merged.times_used += b.times_used;
merged
}
}
macro_rules! apply_field {
($login:ident, $delta:ident, $field:ident) => {
if let Some($field) = $delta.$field.take() {
$login.$field = $field.into();
}
};
}
impl Login {
pub(crate) fn apply_delta(&mut self, mut delta: LoginDelta) {
apply_field!(self, delta, hostname);
apply_field!(self, delta, password);
apply_field!(self, delta, username);
apply_field!(self, delta, time_created);
apply_field!(self, delta, time_last_used);
apply_field!(self, delta, time_password_changed);
apply_field!(self, delta, password_field);
apply_field!(self, delta, username_field);
if let Some(realm) = delta.http_realm.take() {
self.http_realm = if realm.is_empty() { None } else { Some(realm) };
}
if let Some(url) = delta.form_submit_url.take() {
self.form_submit_url = if url.is_empty() { None } else { Some(url) };
}
self.times_used += delta.times_used;
}
pub(crate) fn delta(&self, older: &Login) -> LoginDelta {
let mut delta = LoginDelta::default();
if self.form_submit_url != older.form_submit_url {
delta.form_submit_url = Some(self.form_submit_url.clone().unwrap_or_default());
}
if self.http_realm != older.http_realm {
delta.http_realm = Some(self.http_realm.clone().unwrap_or_default());
}
if self.hostname != older.hostname {
delta.hostname = Some(self.hostname.clone());
}
if self.username != older.username {
delta.username = Some(self.username.clone());
}
if self.password != older.password {
delta.password = Some(self.password.clone());
}
if self.password_field != older.password_field {
delta.password_field = Some(self.password_field.clone());
}
if self.username_field != older.username_field {
delta.username_field = Some(self.username_field.clone());
}
if self.time_created > 0 && self.time_created != older.time_created {
delta.time_created = Some(self.time_created);
}
if self.time_last_used > 0 && self.time_last_used != older.time_last_used {
delta.time_last_used = Some(self.time_last_used);
}
if self.time_password_changed > 0
&& self.time_password_changed != older.time_password_changed
{
delta.time_password_changed = Some(self.time_password_changed);
}
if self.times_used > 0 && self.times_used != older.times_used {
delta.times_used = self.times_used - older.times_used;
}
delta
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invalid_payload_timestamps() {
#[allow(clippy::unreadable_literal)]
let bad_timestamp = 18446732429235952000u64;
let bad_payload: sync15::Payload = serde_json::from_value(serde_json::json!({
"id": "123412341234",
"formSubmitURL": "https://www.example.com/submit",
"hostname": "https://www.example.com",
"username": "test",
"password": "test",
"timeCreated": bad_timestamp,
"timeLastUsed": "some other garbage",
"timePasswordChanged": -30,
}))
.unwrap();
let login = SyncLoginData::from_payload(bad_payload, ServerTimestamp::default())
.unwrap()
.inbound
.0
.unwrap();
assert_eq!(login.time_created, 0);
assert_eq!(login.time_last_used, 0);
assert_eq!(login.time_password_changed, 0);
let now64 = util::system_time_ms_i64(std::time::SystemTime::now());
let good_payload: sync15::Payload = serde_json::from_value(serde_json::json!({
"id": "123412341234",
"formSubmitURL": "https://www.example.com/submit",
"hostname": "https://www.example.com",
"username": "test",
"password": "test",
"timeCreated": now64 - 100,
"timeLastUsed": now64 - 50,
"timePasswordChanged": now64 - 25,
}))
.unwrap();
let login = SyncLoginData::from_payload(good_payload, ServerTimestamp::default())
.unwrap()
.inbound
.0
.unwrap();
assert_eq!(login.time_created, now64 - 100);
assert_eq!(login.time_last_used, now64 - 50);
assert_eq!(login.time_password_changed, now64 - 25);
}
#[test]
fn test_url_fixups() -> Result<()> {
for input in &[
"https://site.com",
"http://site.com:1234",
"ftp://ftp.site.com",
"moz-proxy://127.0.0.1:8888",
"chrome://MyLegacyExtension",
"file://",
"https://[::1]",
] {
assert_eq!(Login::validate_and_fixup_origin(input)?, None);
}
for (input, output) in &[
("https://site.com/", "https://site.com"),
("http://site.com:1234/", "http://site.com:1234"),
("http://example.com/foo?query=wtf#bar", "http://example.com"),
("http://example.com/foo#bar", "http://example.com"),
(
"http://username:password@example.com/",
"http://example.com",
),
("http://😍.com/", "http://xn--r28h.com"),
("https://[0:0:0:0:0:0:0:1]", "https://[::1]"),
("file:///", "file://"),
("file://foo/bar", "file://"),
("file://foo/bar/", "file://"),
("moz-proxy://127.0.0.1:8888/", "moz-proxy://127.0.0.1:8888"),
(
"moz-proxy://127.0.0.1:8888/foo",
"moz-proxy://127.0.0.1:8888",
),
("chrome://MyLegacyExtension/", "chrome://MyLegacyExtension"),
(
"chrome://MyLegacyExtension/foo",
"chrome://MyLegacyExtension",
),
] {
assert_eq!(
Login::validate_and_fixup_origin(input)?,
Some((*output).into())
);
}
Ok(())
}
#[test]
fn test_check_valid() {
#[derive(Debug, Clone)]
struct TestCase {
login: Login,
should_err: bool,
expected_err: &'static str,
}
let valid_login = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_empty_hostname = Login {
hostname: "".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_empty_password = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "".into(),
..Login::default()
};
let login_with_form_submit_and_http_realm = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
form_submit_url: Some("https://www.example.com".into()),
password: "test".into(),
..Login::default()
};
let login_without_form_submit_or_http_realm = Login {
hostname: "https://www.example.com".into(),
password: "test".into(),
..Login::default()
};
let login_with_null_http_realm = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.\0com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_null_username = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "\0".into(),
password: "test".into(),
..Login::default()
};
let login_with_null_password = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "username".into(),
password: "test\0".into(),
..Login::default()
};
let login_with_newline_hostname = Login {
hostname: "\rhttps://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_newline_username_field = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
username_field: "\n".into(),
..Login::default()
};
let login_with_newline_realm = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("foo\nbar".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_newline_password = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test\n".into(),
..Login::default()
};
let login_with_period_username_field = Login {
hostname: "https://www.example.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
username_field: ".".into(),
..Login::default()
};
let login_with_period_form_submit_url = Login {
form_submit_url: Some(".".into()),
hostname: "https://www.example.com".into(),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_javascript_form_submit_url = Login {
form_submit_url: Some("javascript:".into()),
hostname: "https://www.example.com".into(),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_malformed_origin_parens = Login {
hostname: " (".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_host_unicode = Login {
hostname: "http://💖.com".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_hostname_trailing_slash = Login {
hostname: "https://www.example.com/".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_hostname_expanded_ipv6 = Login {
hostname: "https://[0:0:0:0:0:0:1:1]".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_unknown_protocol = Login {
hostname: "moz-proxy://127.0.0.1:8888".into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let test_cases = [
TestCase {
login: valid_login,
should_err: false,
expected_err: "",
},
TestCase {
login: login_with_empty_hostname,
should_err: true,
expected_err: "Invalid login: Origin is empty",
},
TestCase {
login: login_with_empty_password,
should_err: true,
expected_err: "Invalid login: Password is empty",
},
TestCase {
login: login_with_form_submit_and_http_realm,
should_err: true,
expected_err: "Invalid login: Both `formSubmitUrl` and `httpRealm` are present",
},
TestCase {
login: login_without_form_submit_or_http_realm,
should_err: true,
expected_err: "Invalid login: Neither `formSubmitUrl` or `httpRealm` are present",
},
TestCase {
login: login_with_null_http_realm,
should_err: true,
expected_err: "Invalid login: Login has illegal field: `httpRealm` contains Nul",
},
TestCase {
login: login_with_null_username,
should_err: true,
expected_err: "Invalid login: Login has illegal field: `username` contains Nul",
},
TestCase {
login: login_with_null_password,
should_err: true,
expected_err: "Invalid login: Login has illegal field: `password` contains Nul",
},
TestCase {
login: login_with_newline_hostname,
should_err: true,
expected_err: "Invalid login: Login has illegal field: `hostname` contains newline",
},
TestCase {
login: login_with_newline_realm,
should_err: true,
expected_err:
"Invalid login: Login has illegal field: `httpRealm` contains newline",
},
TestCase {
login: login_with_newline_username_field,
should_err: true,
expected_err:
"Invalid login: Login has illegal field: `usernameField` contains newline",
},
TestCase {
login: login_with_newline_password,
should_err: false,
expected_err: "",
},
TestCase {
login: login_with_period_username_field,
should_err: true,
expected_err: "Invalid login: Login has illegal field: `usernameField` is a period",
},
TestCase {
login: login_with_period_form_submit_url,
should_err: false,
expected_err: "",
},
TestCase {
login: login_with_javascript_form_submit_url,
should_err: false,
expected_err: "",
},
TestCase {
login: login_with_malformed_origin_parens,
should_err: true,
expected_err: "Invalid login: Login has illegal field: Origin is Malformed",
},
TestCase {
login: login_with_host_unicode,
should_err: true,
expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
},
TestCase {
login: login_with_hostname_trailing_slash,
should_err: true,
expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
},
TestCase {
login: login_with_hostname_expanded_ipv6,
should_err: true,
expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
},
TestCase {
login: login_with_unknown_protocol,
should_err: false,
expected_err: "",
},
];
for tc in &test_cases {
let actual = tc.login.check_valid();
if tc.should_err {
assert!(actual.is_err(), "{:#?}", tc);
assert_eq!(
tc.expected_err,
actual.unwrap_err().to_string(),
"{:#?}",
tc,
);
} else {
assert!(actual.is_ok(), "{:#?}", tc);
assert!(
tc.login.clone().fixup().is_ok(),
"Fixup failed after check_valid passed: {:#?}",
&tc,
);
}
}
}
#[test]
fn test_fixup() {
#[derive(Debug, Default)]
struct TestCase {
login: Login,
fixedup_host: Option<&'static str>,
fixedup_form_submit_url: Option<String>,
}
let login_with_full_url = Login {
hostname: "http://example.com/foo?query=wtf#bar".into(),
form_submit_url: Some("http://example.com/foo?query=wtf#bar".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_host_unicode = Login {
hostname: "http://😍.com".into(),
form_submit_url: Some("http://😍.com".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_period_fsu = Login {
hostname: "https://example.com".into(),
form_submit_url: Some(".".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_empty_fsu = Login {
hostname: "https://example.com".into(),
form_submit_url: Some("".into()),
username: "test".into(),
password: "test".into(),
..Login::default()
};
let login_with_form_submit_and_http_realm = Login {
hostname: "https://www.example.com".into(),
form_submit_url: Some("https://www.example.com".into()),
http_realm: Some("\n".into()),
password: "test".into(),
..Login::default()
};
let test_cases = [
TestCase {
login: login_with_full_url,
fixedup_host: "http://example.com".into(),
fixedup_form_submit_url: Some("http://example.com".into()),
},
TestCase {
login: login_with_host_unicode,
fixedup_host: "http://xn--r28h.com".into(),
fixedup_form_submit_url: Some("http://xn--r28h.com".into()),
},
TestCase {
login: login_with_period_fsu,
fixedup_form_submit_url: Some("".into()),
..TestCase::default()
},
TestCase {
login: login_with_form_submit_and_http_realm,
fixedup_form_submit_url: Some("https://www.example.com".into()),
..TestCase::default()
},
TestCase {
login: login_with_empty_fsu,
fixedup_form_submit_url: Some("".into()),
..TestCase::default()
},
];
for tc in &test_cases {
let login = tc.login.clone().fixup().expect("should work");
if let Some(expected) = tc.fixedup_host {
assert_eq!(login.hostname, expected, "hostname not fixed in {:#?}", tc);
}
assert_eq!(
login.form_submit_url, tc.fixedup_form_submit_url,
"form_submit_url not fixed in {:#?}",
tc,
);
login.check_valid().unwrap_or_else(|e| {
panic!("Fixup produces invalid record: {:#?}", (e, &tc, &login));
});
assert_eq!(
login.clone().fixup().unwrap(),
login,
"fixup did not reach fixed point for testcase: {:#?}",
tc,
);
}
}
#[test]
fn test_username_field_requires_a_form_target() {
let bad_payload: sync15::Payload = serde_json::from_value(serde_json::json!({
"id": "123412341234",
"httpRealm": "test",
"hostname": "https://www.example.com",
"username": "test",
"password": "test",
"usernameField": "invalid"
}))
.unwrap();
let login: Login = bad_payload.clone().into_record().unwrap();
assert_eq!(login.username_field, "invalid");
assert!(login.check_valid().is_err());
assert_eq!(login.fixup().unwrap().username_field, "");
let login = SyncLoginData::from_payload(bad_payload, ServerTimestamp::default())
.unwrap()
.inbound
.0
.unwrap();
assert_eq!(login.username_field, "");
}
#[test]
fn test_password_field_requires_a_form_target() {
let bad_payload: sync15::Payload = serde_json::from_value(serde_json::json!({
"id": "123412341234",
"httpRealm": "test",
"hostname": "https://www.example.com",
"username": "test",
"password": "test",
"passwordField": "invalid"
}))
.unwrap();
let login: Login = bad_payload.into_record().unwrap();
assert_eq!(login.password_field, "invalid");
assert!(login.check_valid().is_err());
assert_eq!(login.fixup().unwrap().password_field, "");
}
}