wkd: add support

This commit is contained in:
Vincent Breitmoser 2020-01-30 18:19:39 +01:00
parent 2e1956fcfa
commit deb3a0373b
No known key found for this signature in database
GPG Key ID: 7BD18320DEADFA11
6 changed files with 275 additions and 44 deletions

11
Cargo.lock generated
View File

@ -663,7 +663,7 @@ dependencies = [
"fs2 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)",
"hex 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
"idna 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
"multipart 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)",
"pathdiff 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)",
@ -675,6 +675,7 @@ dependencies = [
"time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)",
"url 1.7.2 (registry+https://github.com/rust-lang/crates.io-index)",
"walkdir 2.2.9 (registry+https://github.com/rust-lang/crates.io-index)",
"zbase32 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@ -689,7 +690,7 @@ dependencies = [
"hex 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
"idna 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
"indicatif 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
"multipart 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)",
"pathdiff 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"rand 0.6.5 (registry+https://github.com/rust-lang/crates.io-index)",
@ -2360,6 +2361,11 @@ name = "yansi"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "zbase32"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
[metadata]
"checksum aho-corasick 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)" = "58fb5e95d83b38284460a5fda7d6470aa0b8844d283a0b614b8535e880800d2d"
"checksum ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
@ -2630,3 +2636,4 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
"checksum ws2_32-sys 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e"
"checksum yansi 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d60c3b48c9cdec42fb06b3b84b5b087405e1fa1c644a1af3930e4dfafe93de48"
"checksum yansi 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71"
"checksum zbase32 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "0f9079049688da5871a7558ddacb7f04958862c703e68258594cb7a862b5e33f"

View File

@ -22,6 +22,7 @@ idna = "0.1"
fs2 = "0.4"
walkdir = "2.2"
chrono = "0.4"
zbase32 = "0.1.2"
[lib]
name = "hagrid_database"

View File

@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use std::os::unix::fs::PermissionsExt;
use tempfile;
use url;
use url::form_urlencoded;
use pathdiff::diff_paths;
use std::time::SystemTime;
@ -17,6 +17,8 @@ use types::{Email, Fingerprint, KeyID};
use sync::FlockMutexGuard;
use Result;
use wkd;
use tempfile::NamedTempFile;
use openpgp::Cert;
@ -29,10 +31,12 @@ pub struct Filesystem {
keys_dir_full: PathBuf,
keys_dir_quarantined: PathBuf,
keys_dir_published: PathBuf,
keys_dir_published_wkd: PathBuf,
keys_dir_log: PathBuf,
links_dir_by_fingerprint: PathBuf,
links_dir_by_keyid: PathBuf,
links_dir_wkd_by_email: PathBuf,
links_dir_by_email: PathBuf,
dry_run: bool,
@ -82,18 +86,22 @@ impl Filesystem {
let keys_dir_quarantined = keys_internal_dir.join("quarantined");
let keys_dir_log = keys_internal_dir.join("log");
let keys_dir_published = keys_external_dir.join("pub");
let keys_dir_published_wkd = keys_external_dir.join("wkd");
create_dir_all(&keys_dir_full)?;
create_dir_all(&keys_dir_quarantined)?;
create_dir_all(&keys_dir_published)?;
create_dir_all(&keys_dir_published_wkd)?;
create_dir_all(&keys_dir_log)?;
let links_dir = keys_external_dir.join("links");
let links_dir_by_keyid = links_dir.join("by-keyid");
let links_dir_by_fingerprint = links_dir.join("by-fpr");
let links_dir_by_email = links_dir.join("by-email");
let links_dir_wkd_by_email = links_dir.join("wkd");
create_dir_all(&links_dir_by_keyid)?;
create_dir_all(&links_dir_by_fingerprint)?;
create_dir_all(&links_dir_by_email)?;
create_dir_all(&links_dir_wkd_by_email)?;
info!("Opened filesystem database.");
info!("keys_internal_dir: '{}'", keys_internal_dir.display());
@ -106,12 +114,14 @@ impl Filesystem {
keys_dir_full,
keys_dir_published,
keys_dir_published_wkd,
keys_dir_quarantined,
keys_dir_log,
links_dir_by_keyid,
links_dir_by_fingerprint,
links_dir_by_email,
links_dir_wkd_by_email,
dry_run,
})
@ -135,6 +145,12 @@ impl Filesystem {
self.keys_dir_published.join(path_split(&hex))
}
/// Returns the path to the given Fingerprint.
fn fingerprint_to_path_published_wkd(&self, fingerprint: &Fingerprint) -> PathBuf {
let hex = fingerprint.to_string();
self.keys_dir_published_wkd.join(path_split(&hex))
}
/// Returns the path to the given KeyID.
fn link_by_keyid(&self, keyid: &KeyID) -> PathBuf {
let hex = keyid.to_string();
@ -149,12 +165,24 @@ impl Filesystem {
/// Returns the path to the given Email.
fn link_by_email(&self, email: &Email) -> PathBuf {
let email =
url::form_urlencoded::byte_serialize(email.as_str().as_bytes())
let email = form_urlencoded::byte_serialize(email.as_str().as_bytes())
.collect::<String>();
self.links_dir_by_email.join(path_split(&email))
}
/// Returns the WKD path to the given Email.
fn link_wkd_by_email(&self, email: &Email) -> PathBuf {
let (encoded_local_part, domain) = wkd::encode_wkd(email.as_str()).unwrap();
let encoded_domain = form_urlencoded::byte_serialize(domain.as_bytes())
.collect::<PathBuf>();
[
&self.links_dir_wkd_by_email,
&encoded_domain,
&path_split(&encoded_local_part)
].iter().collect()
}
fn read_from_path(&self, path: &Path, allow_internal: bool) -> Option<String> {
use std::fs;
@ -170,6 +198,21 @@ impl Filesystem {
}
}
fn read_from_path_bytes(&self, path: &Path, allow_internal: bool) -> Option<Vec<u8>> {
use std::fs;
if !path.starts_with(&self.keys_external_dir) &&
!(allow_internal && path.starts_with(&self.keys_internal_dir)) {
panic!("Attempted to access file outside expected dirs!");
}
if path.exists() {
fs::read(path).ok()
} else {
None
}
}
/// Returns the Fingerprint the given path is pointing to.
pub fn path_to_fingerprint(path: &Path) -> Option<Fingerprint> {
use std::str::FromStr;
@ -188,7 +231,7 @@ impl Filesystem {
fn path_to_email(path: &Path) -> Option<Email> {
use std::str::FromStr;
let merged = path_merge(path);
let decoded = url::form_urlencoded::parse(merged.as_bytes()).next()?.0;
let decoded = form_urlencoded::parse(merged.as_bytes()).next()?.0;
Email::from_str(&decoded).ok()
}
@ -204,6 +247,52 @@ impl Filesystem {
}
}
fn link_email_vks(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
let path = self.fingerprint_to_path_published(fpr);
let link = self.link_by_email(&email);
let target = diff_paths(&path, link.parent().unwrap()).unwrap();
if link == target {
return Ok(());
}
symlink(&target, ensure_parent(&link)?)
}
fn link_email_wkd(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
let path = self.fingerprint_to_path_published_wkd(fpr);
let link = self.link_wkd_by_email(&email);
let target = diff_paths(&path, link.parent().unwrap()).unwrap();
if link == target {
return Ok(());
}
symlink(&target, ensure_parent(&link)?)
}
fn unlink_email_vks(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
let link = self.link_by_email(&email);
let expected = diff_paths(
&self.fingerprint_to_path_published(fpr),
link.parent().unwrap()
).unwrap();
symlink_unlink_with_check(&link, &expected)
}
fn unlink_email_wkd(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
let link = self.link_wkd_by_email(&email);
let expected = diff_paths(
&self.fingerprint_to_path_published_wkd(fpr),
link.parent().unwrap()
).unwrap();
symlink_unlink_with_check(&link, &expected)
}
fn open_logfile(&self, file_name: &str) -> Result<File> {
let file_path = self.keys_dir_log.join(file_name);
Ok(OpenOptions::new()
@ -275,6 +364,16 @@ fn symlink(symlink_content: &Path, symlink_name: &Path) -> Result<()> {
Ok(())
}
fn symlink_unlink_with_check(link: &Path, expected: &Path) -> Result<()> {
if let Ok(target) = read_link(&link) {
if target == expected {
remove_file(link)?;
}
}
Ok(())
}
impl Database for Filesystem {
type MutexGuard = FlockMutexGuard;
@ -324,6 +423,21 @@ impl Database for Filesystem {
Ok(())
}
fn move_tmp_to_published_wkd(&self, file: Option<NamedTempFile>, fpr: &Fingerprint) -> Result<()> {
if self.dry_run {
return Ok(());
}
let target = self.fingerprint_to_path_published_wkd(fpr);
if let Some(file) = file {
set_permissions(file.path(), Permissions::from_mode(0o644))?;
file.persist(ensure_parent(&target)?)?;
} else if target.exists() {
remove_file(target)?;
}
Ok(())
}
fn write_to_quarantine(&self, fpr: &Fingerprint, content: &[u8]) -> Result<()> {
let mut tempfile = tempfile::Builder::new()
.prefix("key")
@ -400,32 +514,15 @@ impl Database for Filesystem {
return Ok(());
}
let link = self.link_by_email(&email);
let target = diff_paths(&self.fingerprint_to_path_published(fpr),
link.parent().unwrap()).unwrap();
self.link_email_vks(email, fpr)?;
self.link_email_wkd(email, fpr)?;
if link == target {
return Ok(());
}
symlink(&target, ensure_parent(&link)?)
Ok(())
}
fn unlink_email(&self, email: &Email, fpr: &Fingerprint) -> Result<()> {
let link = self.link_by_email(&email);
match read_link(&link) {
Ok(target) => {
let expected = diff_paths(&self.fingerprint_to_path_published(fpr),
link.parent().unwrap()).unwrap();
if target == expected {
remove_file(link)?;
}
}
Err(_) => {}
}
self.unlink_email_vks(email, fpr)?;
self.unlink_email_wkd(email, fpr)?;
Ok(())
}
@ -493,6 +590,12 @@ impl Database for Filesystem {
self.read_from_path(&path, false)
}
// XXX: slow
fn by_email_wkd(&self, email: &Email) -> Option<Vec<u8>> {
let path = self.link_wkd_by_email(&email);
self.read_from_path_bytes(&path, false)
}
// XXX: slow
fn by_kid(&self, kid: &KeyID) -> Option<String> {
let path = self.link_by_keyid(kid);
@ -525,6 +628,22 @@ impl Database for Filesystem {
}
)?;
self.perform_checks(&self.keys_dir_published, &mut tpks,
|_, tpk, primary_fp| {
// check that certificate exists in published wkd path
let path_wkd = self.fingerprint_to_path_published_wkd(&primary_fp);
let should_wkd_exist = tpk.userids().next().is_some();
if should_wkd_exist && !path_wkd.exists() {
return Err(format_err!("Missing wkd for fp {}", primary_fp));
};
if !should_wkd_exist && path_wkd.exists() {
return Err(format_err!("Incorrectly present wkd for fp {}", primary_fp));
};
Ok(())
}
)?;
// check that all subkeys are linked
self.perform_checks(&self.keys_dir_published, &mut tpks,
|_, tpk, primary_fp| {
@ -560,6 +679,11 @@ impl Database for Filesystem {
return Err(format_err!(
"Missing link to key {} for email {}", primary_fp, email));
}
let email_wkd_path = self.link_wkd_by_email(&email);
if !email_wkd_path.exists() {
return Err(format_err!(
"Missing wkd link to key {} for email {}", primary_fp, email));
}
}
Ok(())
}

View File

@ -5,6 +5,8 @@ use std::convert::TryFrom;
use std::path::PathBuf;
use std::str::FromStr;
use openpgp::serialize::SerializeInto;
use chrono::prelude::Utc;
extern crate failure;
@ -23,6 +25,7 @@ extern crate url;
extern crate hex;
extern crate walkdir;
extern crate chrono;
extern crate zbase32;
use tempfile::NamedTempFile;
@ -37,6 +40,7 @@ use openpgp::{
pub mod types;
use types::{Email, Fingerprint, KeyID};
pub mod wkd;
pub mod sync;
mod fs;
@ -175,6 +179,7 @@ pub trait Database: Sync + Send {
fn by_fpr(&self, fpr: &Fingerprint) -> Option<String>;
fn by_kid(&self, kid: &KeyID) -> Option<String>;
fn by_email(&self, email: &Email) -> Option<String>;
fn by_email_wkd(&self, email: &Email) -> Option<Vec<u8>>;
/// Complex operation that updates a Cert in the database.
///
@ -319,6 +324,7 @@ pub trait Database: Sync + Send {
// database consistency might be compromised!
self.move_tmp_to_full(full_tpk_tmp, &fpr_primary)?;
self.move_tmp_to_published(published_tpk_tmp, &fpr_primary)?;
self.regenerate_wkd(&fpr_primary, &published_tpk_clean)?;
let published_tpk_changed = published_tpk_old
.map(|tpk| tpk != published_tpk_clean)
@ -464,6 +470,8 @@ pub trait Database: Sync + Send {
let published_tpk_tmp = self.write_to_temp(&tpk_to_string(&published_tpk_clean)?)?;
self.move_tmp_to_published(published_tpk_tmp, &fpr_primary)?;
self.regenerate_wkd(fpr_primary, &published_tpk_clean)?;
self.update_write_log(&fpr_primary);
if let Err(e) = self.link_email(&email_new, &fpr_primary) {
@ -548,6 +556,8 @@ pub trait Database: Sync + Send {
let published_tpk_tmp = self.write_to_temp(&tpk_to_string(&published_tpk_clean)?)?;
self.move_tmp_to_published(published_tpk_tmp, &fpr_primary)?;
self.regenerate_wkd(fpr_primary, &published_tpk_clean)?;
self.update_write_log(&fpr_primary);
for unpublished_email in unpublished_emails {
@ -593,6 +603,8 @@ pub trait Database: Sync + Send {
.flatten()
.collect();
self.regenerate_wkd(fpr_primary, &tpk)?;
let fingerprints = tpk_get_linkable_fprs(&tpk);
let fpr_checks = fingerprints
@ -624,6 +636,21 @@ pub trait Database: Sync + Send {
}
}
fn regenerate_wkd(
&self,
fpr_primary: &Fingerprint,
published_tpk: &Cert
) -> Result<()> {
let published_wkd_tpk_tmp = if published_tpk.userids().next().is_some() {
Some(self.write_to_temp(&published_tpk.to_vec()?)?)
} else {
None
};
self.move_tmp_to_published_wkd(published_wkd_tpk_tmp, fpr_primary)?;
Ok(())
}
fn check_link_fpr(&self, fpr: &Fingerprint, target: &Fingerprint) -> Result<Option<Fingerprint>>;
fn by_fpr_full(&self, fpr: &Fingerprint) -> Option<String>;
@ -632,6 +659,7 @@ pub trait Database: Sync + Send {
fn write_to_temp(&self, content: &[u8]) -> Result<NamedTempFile>;
fn move_tmp_to_full(&self, content: NamedTempFile, fpr: &Fingerprint) -> Result<()>;
fn move_tmp_to_published(&self, content: NamedTempFile, fpr: &Fingerprint) -> Result<()>;
fn move_tmp_to_published_wkd(&self, content: Option<NamedTempFile>, fpr: &Fingerprint) -> Result<()>;
fn write_to_quarantine(&self, fpr: &Fingerprint, content: &[u8]) -> Result<()>;
fn write_log_append(&self, filename: &str, fpr_primary: &Fingerprint) -> Result<()>;

View File

@ -31,6 +31,16 @@ use std::fs;
use TpkStatus;
use EmailAddressStatus;
fn check_mail_none(db: &impl Database, email: &Email) {
assert!(db.by_email(&email).is_none());
assert!(db.by_email_wkd(&email).is_none());
}
fn check_mail_some(db: &impl Database, email: &Email) {
assert!(db.by_email(&email).is_some());
assert!(db.by_email_wkd(&email).is_some());
}
pub fn test_uid_verification(db: &mut impl Database, log_path: &Path) {
let str_uid1 = "Test A <test_a@example.com>";
let str_uid2 = "Test B <test_b@example.com>";
@ -70,8 +80,8 @@ pub fn test_uid_verification(db: &mut impl Database, log_path: &Path) {
}
// fail to fetch by uid
assert!(db.by_email(&email1).is_none());
assert!(db.by_email(&email2).is_none());
check_mail_none(db, &email1);
check_mail_none(db, &email2);
// verify 1st uid
db.set_email_published(&fpr, &email1).unwrap();
@ -290,7 +300,7 @@ pub fn test_regenerate(db: &mut impl Database, log_path: &Path) {
check_log_entry(log_path, &fpr);
db.regenerate_links(&fpr).unwrap();
assert!(db.by_email(&email1).is_none());
check_mail_none(db, &email1);
assert!(db.by_fpr(&fpr).is_some());
assert!(db.by_fpr(&fpr_sign).is_some());
assert!(db.by_fpr(&fpr_encrypt).is_none());
@ -369,7 +379,7 @@ pub fn test_uid_replacement(db: &mut impl Database, log_path: &Path) {
// verify 1st uid
db.set_email_published(&fpr1, &email1).unwrap();
assert!(db.by_email(&email1).is_some());
check_mail_some(db, &email1);
assert_eq!(Cert::from_bytes(db.by_email(&email1).unwrap().as_bytes()).unwrap()
.fingerprint(), pgp_fpr1);
@ -380,7 +390,7 @@ pub fn test_uid_replacement(db: &mut impl Database, log_path: &Path) {
// verify uid on other key
db.set_email_published(&fpr2, &email1).unwrap();
assert!(db.by_email(&email1).is_some());
check_mail_some(db, &email1);
assert_eq!(Cert::from_bytes(db.by_email(&email1).unwrap().as_bytes()).unwrap()
.fingerprint(), pgp_fpr2);
@ -526,8 +536,8 @@ pub fn test_upload_revoked_tpk(db: &mut impl Database, log_path: &Path) {
db.merge(tpk.clone()).unwrap();
db.set_email_published(&fpr, &email1).unwrap();
assert!(db.by_email(&email1).is_some());
assert!(db.by_email(&email2).is_none());
check_mail_some(db, &email1);
check_mail_none(db, &email2);
tpk = tpk.merge_packets(vec![revocation.into()]).unwrap();
match tpk.revoked(None) {
@ -547,8 +557,8 @@ pub fn test_upload_revoked_tpk(db: &mut impl Database, log_path: &Path) {
unparsed_uids: 0,
}, tpk_status);
assert!(db.by_email(&email1).is_none());
assert!(db.by_email(&email2).is_none());
check_mail_none(db, &email1);
check_mail_none(db, &email2);
}
pub fn test_uid_revocation(db: &mut impl Database, log_path: &Path) {
@ -584,8 +594,8 @@ pub fn test_uid_revocation(db: &mut impl Database, log_path: &Path) {
db.set_email_published(&fpr, &tpk_status.email_status[1].0).unwrap();
// fetch both uids
assert!(db.by_email(&email1).is_some());
assert!(db.by_email(&email2).is_some());
check_mail_some(db, &email1);
check_mail_some(db, &email2);
thread::sleep(time::Duration::from_secs(2));
@ -615,8 +625,8 @@ pub fn test_uid_revocation(db: &mut impl Database, log_path: &Path) {
}, tpk_status);
// Fail to fetch by the revoked uid, ok by the non-revoked one.
assert!(db.by_email(&email1).is_some());
assert!(db.by_email(&email2).is_none());
check_mail_some(db, &email1);
check_mail_none(db, &email2);
}
/* FIXME I couldn't get this to work.
@ -709,7 +719,7 @@ pub fn test_unlink_uid(db: &mut impl Database, log_path: &Path) {
db.merge(tpk.clone()).unwrap().into_tpk_status();
db.set_email_published(&fpr, &email).unwrap();
assert!(db.by_email(&email).is_some());
check_mail_some(db, &email);
// Create a 2nd key with same uid, and revoke the uid.
let tpk_evil = CertBuilder::new().add_userid(uid).generate().unwrap().0;
@ -841,8 +851,8 @@ pub fn test_same_email_1(db: &mut impl Database, log_path: &Path) {
}, tpk_status2);
// fetch by both user ids. We should get nothing.
assert!(&db.by_email(&email1).is_none());
assert!(&db.by_email(&email2).is_none());
check_mail_none(db, &email1);
check_mail_none(db, &email2);
}
// If a key has multiple user ids with the same email address, make

61
database/src/wkd.rs Normal file
View File

@ -0,0 +1,61 @@
use crate::openpgp::types::HashAlgorithm;
use zbase32;
use super::Result;
// cannibalized from
// https://gitlab.com/sequoia-pgp/sequoia/blob/master/net/src/wkd.rs
pub fn encode_wkd(address: impl AsRef<str>) -> Result<(String,String)> {
let (local_part, domain) = split_address(address)?;
let local_part_encoded = encode_local_part(local_part);
Ok((local_part_encoded, domain))
}
fn split_address(email_address: impl AsRef<str>) -> Result<(String,String)> {
let email_address = email_address.as_ref();
let v: Vec<&str> = email_address.split('@').collect();
if v.len() != 2 {
Err(failure::err_msg("Malformed email address".to_owned()))?;
};
// Convert to lowercase without tailoring, i.e. without taking any
// locale into account. See:
// https://doc.rust-lang.org/std/primitive.str.html#method.to_lowercase
let local_part = v[0].to_lowercase();
let domain = v[1].to_lowercase();
Ok((local_part, domain))
}
fn encode_local_part<S: AsRef<str>>(local_part: S) -> String {
let local_part = local_part.as_ref();
let mut digest = vec![0; 20];
let mut ctx = HashAlgorithm::SHA1.context().expect("must be implemented");
ctx.update(local_part.as_bytes());
ctx.digest(&mut digest);
// After z-base-32 encoding 20 bytes, it will be 32 bytes long.
zbase32::encode_full_bytes(&digest[..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_local_part_succed() {
let encoded_part = encode_local_part("test1");
assert_eq!("stnkabub89rpcphiz4ppbxixkwyt1pic", encoded_part);
assert_eq!(32, encoded_part.len());
}
#[test]
fn email_address_from() {
let (local_part, domain) = split_address("test1@example.com").unwrap();
assert_eq!(local_part, "test1");
assert_eq!(domain, "example.com");
}
}