mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Add craft-vcard: vCard reader and writer from MailCraft (#942)
* craft-mime: strict base64, mailto links and code page labels from MailCraft What MailCraft's mime and charset crates still had that craft-mime did not: strict RFC 4648 base64 (crates/charset), mailto: links (RFC 6068, crates/mime) and the Windows code page to charset label table (crates/pst). With these, MailCraft's charset crate has nothing craft-mime lacks. * Add craft-vcard: vCard reader and writer from MailCraft MailCraft crates/vcard at a44a450: vCard 2.1, 3.0 and 4.0 read, 3.0 written, with its limits and tests. The model is the crate's own Contact (the vCard fields of MailCraft's); the birthday is a chrono::NaiveDate. Character sets and strict base64 come from craft-mime. --------- Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
1 parent
85f30007d7
commit
f3944ba991
14 files changed
+3109
No files matched your search
Generated
+9
@@ -1633,6 +1633,15 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-vcard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-mime",
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -92,6 +92,7 @@ craft-timeline = { path = "crates/timeline" }
|
||||
craft-tiff = { path = "crates/tiff" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
craft-update = { path = "crates/update" }
|
||||
craft-vcard = { path = "crates/vcard" }
|
||||
craft-vectorcraft-format = { path = "crates/vectorcraft-format" }
|
||||
craft-vectordrawable = { path = "crates/vectordrawable" }
|
||||
craft-xls = { path = "crates/xls" }
|
||||
|
||||
@@ -248,6 +248,11 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
for 47 more languages (one cargo feature each).
|
||||
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
|
||||
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
|
||||
- [`craft-vcard`](crates/vcard/README.md): vCard contacts, from MailCraft: vCard 2.1, 3.0 and 4.0 read
|
||||
(legacy character sets, quoted-printable, labels and Apple label groups, `KEY` certificates and
|
||||
OpenPGP public keys), vCard 3.0 written. Bounded and panic-free on hostile input. The mapping onto
|
||||
an app's contact type stays in each app.
|
||||
|
||||
- [`craft-vectorcraft-format`](crates/vectorcraft-format/README.md): the envelope of VectorCraft's
|
||||
`.vectorcraft` file: its members, versions, embedded images and the data after the JSON, with the
|
||||
document as a type parameter. The document and its migrations stay in VectorCraft.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "craft-vcard"
|
||||
description = "vCard: contacts read from vCard 2.1, 3.0 and 4.0 text (RFC 2426, RFC 6350) and written as vCard 3.0, with labels, groups, legacy character sets and KEY certificates."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Character sets of vCard 2.1's CHARSET, and strict base64 for KEY.
|
||||
craft-mime = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,48 @@
|
||||
# craft-vcard
|
||||
|
||||
vCard: contacts as vCard text (`.vcf` files, CardDAV address data).
|
||||
|
||||
- `parse(text)` reads vCard 2.1, 3.0 and 4.0, one card or many in a text. `parse_bytes(bytes)`
|
||||
reads a file: UTF-8, UTF-16 with a byte order mark, and vCard 2.1 values each in their own
|
||||
`CHARSET` (quoted-printable or raw), decoded by `craft_mime::charset`.
|
||||
- `write(&[&Contact])` writes vCard 3.0, the version every address book reads. Lines are folded
|
||||
at 75 octets, values are escaped, and no value can start a new property or card.
|
||||
- `Contact` is this crate's own plain model: names, organisation, labelled e-mail addresses and
|
||||
phone numbers, postal addresses, web sites, birthday (`chrono::NaiveDate`), notes, categories,
|
||||
favourite, `UID` and `KEY`.
|
||||
- Labels come from `TYPE` in all three versions' spellings and from Apple's `itemN.X-ABLabel`
|
||||
groups, and are written back the same way.
|
||||
- `KEY` keeps S/MIME certificates and OpenPGP public keys in every form the three versions have
|
||||
(base64, `data:` URIs, PEM, ASCII armour); a key given as an address is kept as an address and
|
||||
never fetched. Anything with the shape of secret key material is dropped.
|
||||
|
||||
## Hostile input
|
||||
|
||||
Nothing panics on any text. What makes no sense is skipped.
|
||||
|
||||
| Limit | Value |
|
||||
|---|---|
|
||||
| cards in one text | 100,000 |
|
||||
| nesting of `BEGIN:VCARD` | 8 |
|
||||
| unfolded line | 1 MiB |
|
||||
| parameters on a line | 64 |
|
||||
| e-mail addresses, phones, addresses, web sites per card | 100 each |
|
||||
| categories per card | 500 |
|
||||
| group labels per card | 400 |
|
||||
| keys per card (`Contact::MAX_KEYS`) | 32 |
|
||||
| one key (`ContactKey::MAX_BYTES`) | 64 KiB |
|
||||
| key URI (`ContactKey::MAX_URI`) | 2048 bytes |
|
||||
| key bytes kept from one text | 64 MiB |
|
||||
|
||||
## What stays in the apps
|
||||
|
||||
Ids, where a contact came from, merging and de-duplication, photos (not read), and the mapping
|
||||
of `Contact` onto the app's own contact type.
|
||||
|
||||
## Origin
|
||||
|
||||
MailCraft `crates/vcard` at
|
||||
[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5),
|
||||
by Martin Ball and the MailCraft contributors (MIT OR Apache-2.0), with its tests. The model
|
||||
types are the vCard fields of MailCraft's `Contact`; the birthday is a `chrono::NaiveDate` where
|
||||
MailCraft has its own date type.
|
||||
@@ -0,0 +1,294 @@
|
||||
//! `KEY`: an S/MIME certificate or an OpenPGP public key on a card, kept as bytes.
|
||||
//!
|
||||
//! Read in every form the versions have: vCard 2.1 (`KEY;X509;ENCODING=BASE64:`), 3.0
|
||||
//! (`KEY;ENCODING=b;TYPE=PGP:`, RFC 2426) and 4.0 (`KEY:data:application/pgp-keys;base64,…` or
|
||||
//! `KEY;MEDIATYPE=application/pgp-keys:https://…`, RFC 6350), and a key written as text (an
|
||||
//! ASCII-armoured OpenPGP key, a PEM certificate).
|
||||
//!
|
||||
//! What a key is follows from its bytes, whatever the card calls it: a certificate is kept as DER
|
||||
//! with the outline of an X.509 certificate (a PEM one is unwrapped), an OpenPGP key as the
|
||||
//! packets of a public key, binary or armoured as it came. A card whose label disagrees with the
|
||||
//! bytes, anything else, and above all secret key material (an armour or PEM label that says
|
||||
//! PRIVATE or SECRET, an OpenPGP secret-key packet, a PKCS #8 or PKCS #12 structure) is dropped.
|
||||
//! Nothing is checked beyond that outline, nothing is trusted, and nothing is fetched: an address
|
||||
//! is kept as an address. Written as vCard 3.0.
|
||||
|
||||
use crate::{ContactKey, KeyKind};
|
||||
|
||||
use crate::lines::ContentLine;
|
||||
use crate::text::{base64_bytes, quoted_printable, unescape};
|
||||
|
||||
/// The schemes an address of a key may have; any other address is dropped.
|
||||
const SCHEMES: [&str; 5] = ["http", "https", "ldap", "ldaps", "openpgp4fpr"];
|
||||
const PEM_BEGIN: &str = "-----BEGIN CERTIFICATE-----";
|
||||
const PEM_END: &str = "-----END CERTIFICATE-----";
|
||||
const PGP_BEGIN: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----";
|
||||
const PGP_END: &str = "-----END PGP PUBLIC KEY BLOCK-----";
|
||||
|
||||
/// Most bytes a value may decode to before what it holds is worked out: a PEM certificate or
|
||||
/// an armoured key is larger than the bytes it is kept as.
|
||||
const MAX_DECODED: usize = ContactKey::MAX_BYTES.saturating_mul(2);
|
||||
/// Most characters of a value read for a key: the most it takes to spell [`MAX_DECODED`] bytes
|
||||
/// (three characters a byte, quoted-printable or percent-encoded), with room for a header.
|
||||
const MAX_VALUE: usize = MAX_DECODED.saturating_mul(3).saturating_add(1024);
|
||||
|
||||
/// The kind a type or media type names: `X509`, `PGP`, `application/pkix-cert`,
|
||||
/// `application/pgp-keys` and the spellings writers use for them.
|
||||
fn kind_named(name: &str) -> Option<KeyKind> {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"x509" | "x.509" | "x-x509" | "application/pkix-cert" | "application/x-x509-ca-cert" | "application/x-x509-user-cert" => Some(KeyKind::X509),
|
||||
"pgp" | "x-pgp" | "gpg" | "openpgp" | "application/pgp-keys" | "application/pgp" | "application/x-pgp-keys" => Some(KeyKind::OpenPgp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of one `KEY` line; `None` for one that holds nothing kept (junk, secret key material,
|
||||
/// a label the bytes don't bear out, a key too large, an address of another scheme).
|
||||
pub(crate) fn read(line: &ContentLine) -> Option<ContactKey> {
|
||||
if line.value.len() > MAX_VALUE {
|
||||
return None;
|
||||
}
|
||||
let mut stated = line.types().iter().chain(line.param("MEDIATYPE").map(str::to_string).iter()).find_map(|t| kind_named(t));
|
||||
let bytes = match line.param("ENCODING").unwrap_or("").to_ascii_uppercase().as_str() {
|
||||
"B" | "BASE64" => base64_bytes(&line.value),
|
||||
"QUOTED-PRINTABLE" | "Q" => quoted_printable(&line.value),
|
||||
_ => {
|
||||
let value = unescape(&line.value);
|
||||
let value = value.trim();
|
||||
if starts_with_ci(value, "data:") {
|
||||
let (named, bytes) = data_uri(value.get(5..).unwrap_or(""))?;
|
||||
stated = named.or(stated);
|
||||
bytes
|
||||
} else if value.starts_with("-----BEGIN") {
|
||||
value.as_bytes().to_vec()
|
||||
} else if line.param("VALUE").is_some_and(|v| v.eq_ignore_ascii_case("text")) {
|
||||
// Text that is neither armour nor PEM is no key (a fingerprint, a note); it is
|
||||
// never taken for the bytes of one.
|
||||
return None;
|
||||
} else {
|
||||
return address(value, stated);
|
||||
}
|
||||
}
|
||||
};
|
||||
if bytes.len() > MAX_DECODED {
|
||||
return None;
|
||||
}
|
||||
key_of(&bytes, stated)
|
||||
}
|
||||
|
||||
fn starts_with_ci(s: &str, start: &str) -> bool {
|
||||
s.get(..start.len()).is_some_and(|head| head.eq_ignore_ascii_case(start))
|
||||
}
|
||||
|
||||
/// An address of a key, with what the card says is there.
|
||||
fn address(value: &str, stated: Option<KeyKind>) -> Option<ContactKey> {
|
||||
let uri = checked_uri(value)?;
|
||||
let media_type = match stated {
|
||||
Some(KeyKind::X509) => ContactKey::X509_MEDIA_TYPE,
|
||||
Some(KeyKind::OpenPgp) => ContactKey::OPENPGP_MEDIA_TYPE,
|
||||
_ => "",
|
||||
};
|
||||
Some(ContactKey { kind: KeyKind::Uri, uri, media_type: media_type.into(), ..Default::default() })
|
||||
}
|
||||
|
||||
/// The key `bytes` hold, however they were spelled: a certificate as DER, an OpenPGP public key as
|
||||
/// it came. `None` for anything else, for secret key material, for bytes that are not the kind
|
||||
/// the card `stated`, and for a key larger than is kept.
|
||||
pub(crate) fn key_of(bytes: &[u8], stated: Option<KeyKind>) -> Option<ContactKey> {
|
||||
let text = std::str::from_utf8(bytes).ok().map(str::trim_start).filter(|t| t.starts_with("-----BEGIN"));
|
||||
let (kind, data) = match text {
|
||||
Some(t) if says_secret(t) => return None,
|
||||
Some(t) if t.starts_with(PEM_BEGIN) => {
|
||||
let body = t.get(PEM_BEGIN.len()..)?;
|
||||
let (body, _) = body.split_once(PEM_END)?;
|
||||
(KeyKind::X509, base64_bytes(body))
|
||||
}
|
||||
Some(t) if t.starts_with(PGP_BEGIN) => {
|
||||
// Up to the end of the armour; what follows it is not kept.
|
||||
let end = t.find(PGP_END)?.checked_add(PGP_END.len())?;
|
||||
let armour = t.get(..end)?;
|
||||
openpgp_public(&armour_body(armour)?).then_some(())?;
|
||||
(KeyKind::OpenPgp, armour.as_bytes().to_vec())
|
||||
}
|
||||
Some(_) => return None,
|
||||
None if is_certificate(bytes) => (KeyKind::X509, bytes.to_vec()),
|
||||
None if openpgp_public(bytes) => (KeyKind::OpenPgp, bytes.to_vec()),
|
||||
None => return None,
|
||||
};
|
||||
let fits = !data.is_empty() && data.len() <= ContactKey::MAX_BYTES;
|
||||
let agrees = stated.is_none_or(|s| s == kind);
|
||||
let whole = kind != KeyKind::X509 || is_certificate(&data);
|
||||
(fits && agrees && whole).then(|| ContactKey { kind, data, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Whether an armour or PEM label in the text says it holds secret key material
|
||||
/// (`-----BEGIN PGP PRIVATE KEY BLOCK-----`, `-----BEGIN RSA PRIVATE KEY-----`,
|
||||
/// `-----BEGIN ENCRYPTED PRIVATE KEY-----`, …), anywhere in it.
|
||||
fn says_secret(text: &str) -> bool {
|
||||
text.lines().map(str::trim).filter(|l| l.starts_with("-----")).any(|l| {
|
||||
let label = l.to_ascii_uppercase();
|
||||
label.contains("PRIVATE") || label.contains("SECRET")
|
||||
})
|
||||
}
|
||||
|
||||
/// The bytes of an ASCII armour (RFC 9580 section 6): after the header lines and the blank line
|
||||
/// that ends them, up to the checksum or the end line. `None` when that isn't base64.
|
||||
fn armour_body(armour: &str) -> Option<Vec<u8>> {
|
||||
let mut lines = armour.lines().skip(1).map(str::trim);
|
||||
let mut body = String::new();
|
||||
// Header lines (`Version: …`, `Version:GnuPG`) end at a blank line; an armour without headers
|
||||
// begins at once. (A colon is never base64.)
|
||||
let mut first = lines.next()?;
|
||||
if first.contains(':') {
|
||||
first = lines.by_ref().find(|l| l.is_empty())?;
|
||||
}
|
||||
for line in std::iter::once(first).chain(lines) {
|
||||
if line.starts_with('=') || line.starts_with("-----") {
|
||||
break;
|
||||
}
|
||||
body.push_str(line);
|
||||
}
|
||||
let compact = body.trim_end_matches('=');
|
||||
craft_mime::base64::STANDARD_NO_PAD.decode(compact).ok().filter(|b| !b.is_empty())
|
||||
}
|
||||
|
||||
/// One DER element: its tag, its contents and what follows it. Only the definite lengths DER
|
||||
/// allows, of at most four bytes.
|
||||
fn element(bytes: &[u8]) -> Option<(u8, &[u8], &[u8])> {
|
||||
let tag = *bytes.first()?;
|
||||
let first = usize::from(*bytes.get(1)?);
|
||||
let (length, start) = if first < 0x80 {
|
||||
(first, 2usize)
|
||||
} else {
|
||||
let count = first.checked_sub(0x80).filter(|n| (1..=4).contains(n))?;
|
||||
let digits = bytes.get(2..2usize.checked_add(count)?)?;
|
||||
let length = digits.iter().try_fold(0usize, |n, d| n.checked_mul(256)?.checked_add(usize::from(*d)))?;
|
||||
(length, 2usize.checked_add(count)?)
|
||||
};
|
||||
let end = start.checked_add(length)?;
|
||||
Some((tag, bytes.get(start..end)?, bytes.get(end..)?))
|
||||
}
|
||||
|
||||
/// Whether the bytes have the outline of an X.509 certificate (RFC 5280 section 4.1): one
|
||||
/// SEQUENCE of the to-be-signed SEQUENCE (beginning with its version or serial number), the
|
||||
/// signature algorithm's SEQUENCE and the signature's BIT STRING, and nothing after it. A private
|
||||
/// key (PKCS #1, PKCS #8, encrypted or not) or a PKCS #12 file does not have it.
|
||||
fn is_certificate(bytes: &[u8]) -> bool {
|
||||
let shape = || -> Option<()> {
|
||||
let (0x30, certificate, []) = element(bytes)? else { return None };
|
||||
let (0x30, signed, rest) = element(certificate)? else { return None };
|
||||
matches!(signed.first(), Some(0xa0 | 0x02)).then_some(())?;
|
||||
let (0x30, _, rest) = element(rest)? else { return None };
|
||||
let (0x03, _, []) = element(rest)? else { return None };
|
||||
Some(())
|
||||
};
|
||||
shape().is_some()
|
||||
}
|
||||
|
||||
/// Whether the bytes are OpenPGP packets (RFC 9580 section 4.2) that make a public key: a
|
||||
/// public-key packet first, every packet whole, and no secret-key or secret-subkey packet
|
||||
/// anywhere.
|
||||
fn openpgp_public(bytes: &[u8]) -> bool {
|
||||
let mut at = 0usize;
|
||||
let mut first = true;
|
||||
while at < bytes.len() {
|
||||
let Some((tag, next)) = packet(bytes, at) else { return false };
|
||||
if matches!(tag, 5 | 7) || (first && tag != 6) {
|
||||
return false;
|
||||
}
|
||||
first = false;
|
||||
at = next;
|
||||
}
|
||||
!first
|
||||
}
|
||||
|
||||
/// The tag of the packet at `at` and where the next one begins; `None` for a header that isn't
|
||||
/// one or a packet that runs past the end. A partial length (only data packets have one) is not a
|
||||
/// key's.
|
||||
fn packet(bytes: &[u8], at: usize) -> Option<(u8, usize)> {
|
||||
let header = *bytes.get(at)?;
|
||||
if header & 0x80 == 0 {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| bytes.get(at.checked_add(i)?).map(|b| usize::from(*b));
|
||||
let big_endian = |from: usize, count: usize| (from..from.checked_add(count)?).try_fold(0usize, |n, i| n.checked_mul(256)?.checked_add(byte(i)?));
|
||||
let (tag, length, header_len) = if header & 0x40 == 0 {
|
||||
// The old format: the tag in bits 5 to 2, the length's size in bits 1 and 0.
|
||||
let tag = (header >> 2) & 0x0f;
|
||||
match header & 0x03 {
|
||||
0 => (tag, byte(1)?, 2usize),
|
||||
1 => (tag, big_endian(1, 2)?, 3),
|
||||
2 => (tag, big_endian(1, 4)?, 5),
|
||||
// An indeterminate length, which only data packets may have: not a key's.
|
||||
_ => return None,
|
||||
}
|
||||
} else {
|
||||
let tag = header & 0x3f;
|
||||
match byte(1)? {
|
||||
n @ 0..=191 => (tag, n, 2usize),
|
||||
n @ 192..=223 => (tag, n.checked_sub(192)?.checked_mul(256)?.checked_add(byte(2)?)?.checked_add(192)?, 3),
|
||||
255 => (tag, big_endian(2, 4)?, 6),
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
let next = at.checked_add(header_len)?.checked_add(length)?;
|
||||
(next <= bytes.len()).then_some((tag, next))
|
||||
}
|
||||
|
||||
/// The kind its media type names and the bytes of a `data:` URI (after the scheme):
|
||||
/// `application/pgp-keys;base64,mQIN…` or percent-encoded text.
|
||||
fn data_uri(rest: &str) -> Option<(Option<KeyKind>, Vec<u8>)> {
|
||||
let (head, data) = rest.split_once(',')?;
|
||||
let mut parts = head.split(';');
|
||||
let named = parts.next().and_then(kind_named);
|
||||
let base64 = parts.any(|p| p.trim().eq_ignore_ascii_case("base64"));
|
||||
Some((named, if base64 { base64_bytes(data) } else { percent_decoded(data) }))
|
||||
}
|
||||
|
||||
/// `%41` as `A`; a `%` without two hex digits after it is kept.
|
||||
fn percent_decoded(s: &str) -> Vec<u8> {
|
||||
let bytes = s.as_bytes();
|
||||
let hex = |at: usize| bytes.get(at).and_then(|b| char::from(*b).to_digit(16)).and_then(|d| u8::try_from(d).ok());
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0usize;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'%'
|
||||
&& let (Some(high), Some(low)) = (hex(i.saturating_add(1)), hex(i.saturating_add(2)))
|
||||
{
|
||||
out.push(high.saturating_mul(16).saturating_add(low));
|
||||
i = i.saturating_add(3);
|
||||
} else {
|
||||
out.push(b);
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The address when it is one a key may be at: a known scheme, no white space, control or
|
||||
/// format characters (a right-to-left override, a zero-width space) or backslashes, not too long.
|
||||
pub(crate) fn checked_uri(value: &str) -> Option<String> {
|
||||
let (scheme, rest) = value.split_once(':')?;
|
||||
let known = SCHEMES.iter().any(|s| s.eq_ignore_ascii_case(scheme));
|
||||
// (A backslash would be read back as an escape.)
|
||||
let clean = !rest.is_empty() && !value.chars().any(|c| c.is_whitespace() || c.is_control() || crate::text::is_format(c) || c == '\\');
|
||||
(known && clean && value.len() <= ContactKey::MAX_URI).then(|| value.to_string())
|
||||
}
|
||||
|
||||
/// The `KEY` line of a key, as vCard 3.0 writes it; `None` for a key that would not read back as
|
||||
/// itself (an unknown kind, bytes that aren't a certificate or a public key, too many of them).
|
||||
///
|
||||
/// An address of a key is not written: vCard 3.0 (RFC 2426 section 3.7.2) lets `KEY` be binary
|
||||
/// or text only, and an address written as text would be read elsewhere as the key itself. It
|
||||
/// stays on the contact.
|
||||
pub(crate) fn line(key: &ContactKey) -> Option<String> {
|
||||
let kind = match key.kind {
|
||||
KeyKind::X509 => "X509",
|
||||
KeyKind::OpenPgp => "PGP",
|
||||
KeyKind::Uri => return None,
|
||||
};
|
||||
// Only what the reader would keep as this very key: never secret material a mailbox file
|
||||
// may hold.
|
||||
(key_of(&key.data, Some(key.kind)).as_ref() == Some(key))
|
||||
.then(|| format!("KEY;TYPE={kind};ENCODING=b:{}", craft_mime::base64::STANDARD.encode(&key.data)))
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! `KEY`: certificates and OpenPGP public keys read in every version's form, known by their bytes,
|
||||
//! secret key material never kept, written back as 3.0, capped, and never a panic.
|
||||
|
||||
use crate::{Contact, ContactKey, KeyKind};
|
||||
use craft_mime::base64::STANDARD;
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::{parse, write};
|
||||
|
||||
/// Made-up bytes with the outline of a DER certificate: SEQUENCE { SEQUENCE { INTEGER 7 },
|
||||
/// SEQUENCE {}, BIT STRING }.
|
||||
const DER: &[u8] = &[0x30, 0x0a, 0x30, 0x03, 0x02, 0x01, 0x07, 0x30, 0x00, 0x03, 0x01, 0x00];
|
||||
/// Made-up OpenPGP packets: an old-format public-key packet (tag 6) of six bytes.
|
||||
const PGP: &[u8] = &[0x99, 0x00, 0x06, 0x04, 0x5e, 0x00, 0x00, 0x01, 0x16];
|
||||
|
||||
/// One DER element, its length in the short or the long form.
|
||||
fn der(tag: u8, content: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![tag];
|
||||
match content.len() {
|
||||
n if n < 0x80 => out.push(n as u8),
|
||||
n if n < 0x100 => out.extend([0x81, n as u8]),
|
||||
n => out.extend([0x82, (n >> 8) as u8, n as u8]),
|
||||
}
|
||||
out.extend(content);
|
||||
out
|
||||
}
|
||||
|
||||
/// A certificate's outline whose signature is `signature`.
|
||||
fn certificate(serial: &[u8], signature: &[u8]) -> Vec<u8> {
|
||||
let signed = der(0x30, &der(0x02, serial));
|
||||
der(0x30, &[signed, der(0x30, &[]), der(0x03, signature)].concat())
|
||||
}
|
||||
|
||||
/// An ASCII armour of OpenPGP packets, with a header and a checksum line.
|
||||
fn armoured(packets: &[u8]) -> String {
|
||||
format!("-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: made up\n\n{}\n=AAAA\n-----END PGP PUBLIC KEY BLOCK-----", STANDARD.encode(packets))
|
||||
}
|
||||
|
||||
fn pem(der: &[u8]) -> String {
|
||||
format!("-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", STANDARD.encode(der))
|
||||
}
|
||||
|
||||
/// `text` with `\`, `;`, `,` and line breaks escaped as a vCard value.
|
||||
fn escaped(text: &str) -> String {
|
||||
crate::text::escape(text)
|
||||
}
|
||||
|
||||
fn keys(version: &str, properties: &str) -> Vec<ContactKey> {
|
||||
let got = parse(&format!("BEGIN:VCARD\r\nVERSION:{version}\r\nFN:Ada\r\n{properties}\r\nEND:VCARD\r\n"));
|
||||
assert_eq!(got.len(), 1, "{properties}");
|
||||
got.into_iter().next().map(|c| c.keys).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn x509(data: &[u8]) -> ContactKey {
|
||||
ContactKey { kind: KeyKind::X509, data: data.to_vec(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn openpgp(data: &[u8]) -> ContactKey {
|
||||
ContactKey { kind: KeyKind::OpenPgp, data: data.to_vec(), ..Default::default() }
|
||||
}
|
||||
|
||||
fn at(uri: &str, media_type: &str) -> ContactKey {
|
||||
ContactKey { kind: KeyKind::Uri, uri: uri.into(), media_type: media_type.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_2_1_keys() {
|
||||
let (der, pgp) = (STANDARD.encode(DER), STANDARD.encode(PGP));
|
||||
let (head, tail) = der.split_at(4);
|
||||
let got = keys("2.1", &format!("KEY;X509;ENCODING=BASE64:{head}\r\n {tail}\r\n\r\nKEY;PGP;BASE64:{pgp}"));
|
||||
assert_eq!(got, [x509(DER), openpgp(PGP)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_3_0_keys() {
|
||||
let (der, pgp) = (STANDARD.encode(DER), STANDARD.encode(PGP));
|
||||
let (head, tail) = pgp.split_at(5);
|
||||
let armour = armoured(PGP);
|
||||
let got = keys(
|
||||
"3.0",
|
||||
&format!(
|
||||
"KEY;ENCODING=b;TYPE=X509:{der}\r\nkey;type=pgp;encoding=B:{head}\r\n {tail}\r\nKEY;TYPE=PGP:{}\r\nKEY;VALUE=uri;TYPE=X509:ldap://ldap.example.org/cn=Ada",
|
||||
escaped(&format!("{armour}\nWhat follows the armour is not kept"))
|
||||
),
|
||||
);
|
||||
assert_eq!(got, [x509(DER), openpgp(PGP), openpgp(armour.as_bytes()), at("ldap://ldap.example.org/cn=Ada", ContactKey::X509_MEDIA_TYPE)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_4_0_keys() {
|
||||
let (der, pgp) = (STANDARD.encode(DER), STANDARD.encode(PGP));
|
||||
let got = keys(
|
||||
"4.0",
|
||||
&format!(
|
||||
"KEY:data:application/pgp-keys;base64,{pgp}\r\nKEY:data:application/pkix-cert;base64\\,{der}\r\nKEY:data:application/pgp-keys,%99%00%06%04%5E%00%00%01%16\r\n\
|
||||
KEY;MEDIATYPE=application/pgp-keys:https://keys.example.org/ada.asc\r\nKEY;TYPE=work:openpgp4fpr:ABCDEF0123456789ABCDEF0123456789ABCDEF01\r\nKEY:http://example.org/ada.cer"
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
openpgp(PGP),
|
||||
x509(DER),
|
||||
// (The same key twice is kept once.)
|
||||
at("https://keys.example.org/ada.asc", ContactKey::OPENPGP_MEDIA_TYPE),
|
||||
at("openpgp4fpr:ABCDEF0123456789ABCDEF0123456789ABCDEF01", ""),
|
||||
at("http://example.org/ada.cer", ""),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_without_a_type_is_known_by_its_bytes() {
|
||||
let got = keys(
|
||||
"3.0",
|
||||
&format!(
|
||||
"KEY;ENCODING=b:{}\r\nKEY;ENCODING=b:{}\r\nKEY:{}\r\nKEY:data:,{}\r\nKEY;ENCODING=b:{}",
|
||||
STANDARD.encode(DER),
|
||||
STANDARD.encode(PGP),
|
||||
escaped(&pem(DER)),
|
||||
armoured(PGP).replace('\n', "%0A"),
|
||||
STANDARD.encode(b"neither")
|
||||
),
|
||||
);
|
||||
assert_eq!(got, [x509(DER), openpgp(PGP), openpgp(armoured(PGP).as_bytes())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_key_material_is_never_kept() {
|
||||
let pkcs8 = der(0x30, &[der(0x02, &[0]), der(0x30, &der(0x06, &[0x2a, 0x86, 0x48])), der(0x04, &[1, 2, 3, 4])].concat());
|
||||
let pkcs12 = der(0x30, &[der(0x02, &[3]), der(0x30, &der(0x06, &[0x2a])), der(0x30, &[])].concat());
|
||||
let encrypted_pkcs8 = der(0x30, &[der(0x30, &der(0x06, &[0x2a])), der(0x04, &[9, 9])].concat());
|
||||
// A public-key packet followed by a secret-subkey packet (old format, tag 7).
|
||||
let with_secret_subkey = [PGP, &[0x9d, 0x00, 0x01, 0x00]].concat();
|
||||
for (what, line) in [
|
||||
("an armoured secret key labelled PGP", format!("KEY;TYPE=PGP:{}", escaped(&armoured(PGP).replace("PUBLIC", "PRIVATE")))),
|
||||
(
|
||||
"a PEM private key labelled X509",
|
||||
format!("KEY;TYPE=X509:{}", escaped(&format!("-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----", STANDARD.encode(&pkcs8)))),
|
||||
),
|
||||
("a PEM RSA key", format!("KEY:{}", escaped("-----BEGIN RSA PRIVATE KEY-----\nMIIB\n-----END RSA PRIVATE KEY-----"))),
|
||||
(
|
||||
"a certificate with a key after it",
|
||||
format!(
|
||||
"KEY;TYPE=X509:{}",
|
||||
escaped(&format!("{}-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIB\n-----END ENCRYPTED PRIVATE KEY-----", pem(DER)))
|
||||
),
|
||||
),
|
||||
("a secret-key packet, old format (lQAA)", "KEY;TYPE=PGP;ENCODING=b:lQAA".to_string()),
|
||||
("a secret-key packet, new format", format!("KEY;ENCODING=b:{}", STANDARD.encode([0xc5, 0x01, 0x04]))),
|
||||
("a secret-subkey packet, new format", format!("KEY;ENCODING=b:{}", STANDARD.encode([PGP, &[0xc7, 0x00]].concat()))),
|
||||
("a secret subkey after a public key", format!("KEY;TYPE=PGP;ENCODING=b:{}", STANDARD.encode(&with_secret_subkey))),
|
||||
("a secret subkey inside a public armour", format!("KEY;TYPE=PGP:{}", escaped(&armoured(&with_secret_subkey)))),
|
||||
("PKCS #8 labelled X509", format!("KEY;TYPE=X509;ENCODING=b:{}", STANDARD.encode(&pkcs8))),
|
||||
("PKCS #8 without a type", format!("KEY;ENCODING=b:{}", STANDARD.encode(&pkcs8))),
|
||||
("encrypted PKCS #8", format!("KEY:data:application/pkix-cert;base64,{}", STANDARD.encode(&encrypted_pkcs8))),
|
||||
("PKCS #12", format!("KEY;TYPE=X509;ENCODING=b:{}", STANDARD.encode(&pkcs12))),
|
||||
("PKCS #8 in PEM as a certificate", format!("KEY:{}", escaped(&pem(&pkcs8)))),
|
||||
] {
|
||||
assert!(keys("3.0", &line).is_empty(), "{what}");
|
||||
}
|
||||
// And a mailbox file that holds such bytes never has them written to a card.
|
||||
let c =
|
||||
Contact { display_name: "Ada".into(), keys: vec![x509(&pkcs8), openpgp(&with_secret_subkey), openpgp(&[0x95, 0, 0])], ..Default::default() };
|
||||
assert!(!write(&[&c]).contains("KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_never_taken_for_binary() {
|
||||
for line in [
|
||||
"KEY;VALUE=text:0abc",
|
||||
"KEY;TYPE=X509;VALUE=text:0\\n\\u{3}",
|
||||
"KEY;TYPE=X509;ENCODING=QUOTED-PRINTABLE:0hello",
|
||||
"KEY;TYPE=PGP;VALUE=text:ABCD 1234 EF56",
|
||||
"KEY;TYPE=PGP:ABCD1234EF56",
|
||||
// Text that looks like an address is text, not the address of a key.
|
||||
"KEY;VALUE=text:https://keys.example.org/ada.asc",
|
||||
"KEY;TYPE=PGP;VALUE=TEXT:openpgp4fpr:ABCDEF01",
|
||||
] {
|
||||
assert!(keys("3.0", line).is_empty(), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indeterminate_length_is_no_keys() {
|
||||
// Old format, tag 6, length type 3 (the rest of the bytes): only data packets may have it.
|
||||
for header in [0x9b_u8, 0xbb] {
|
||||
let bytes = [header, 0x04, 0x5e, 0x00, 0x00, 0x01, 0x16];
|
||||
assert!(keys("3.0", &format!("KEY;TYPE=PGP;ENCODING=b:{}", STANDARD.encode(bytes))).is_empty(), "{header:#x}");
|
||||
assert!(keys("3.0", &format!("KEY;ENCODING=b:{}", STANDARD.encode([PGP, &bytes].concat()))).is_empty(), "{header:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armour_headers_without_a_space_are_headers() {
|
||||
let armour = format!(
|
||||
"-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion:GnuPG v2\nComment:made up\n\n{}\n=AAAA\n-----END PGP PUBLIC KEY BLOCK-----",
|
||||
STANDARD.encode(PGP)
|
||||
);
|
||||
assert_eq!(keys("3.0", &format!("KEY;TYPE=PGP:{}", escaped(&armour))), [openpgp(armour.as_bytes())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_spelling_of_a_certificate_is_kept_as_der_and_the_bytes_say_the_kind() {
|
||||
let quoted: String = pem(DER).bytes().map(|b| format!("={b:02X}")).collect();
|
||||
let percent: String = pem(DER).bytes().map(|b| format!("%{b:02X}")).collect();
|
||||
let got = keys(
|
||||
"3.0",
|
||||
&format!(
|
||||
"KEY;TYPE=X509;ENCODING=QUOTED-PRINTABLE:{quoted}\r\nKEY:data:application/pkix-cert,{percent}\r\nKEY;TYPE=X509;ENCODING=b:{}\r\nKEY;ENCODING=b:{}",
|
||||
STANDARD.encode(pem(DER)),
|
||||
STANDARD.encode(pem(&certificate(&[8], &[0])))
|
||||
),
|
||||
);
|
||||
// The first three are the same certificate, kept once.
|
||||
assert_eq!(got, [x509(DER), x509(&certificate(&[8], &[0]))]);
|
||||
// A label the bytes don't bear out is dropped.
|
||||
for line in [
|
||||
format!("KEY;TYPE=PGP:{}", escaped(&pem(DER))),
|
||||
format!("KEY;TYPE=PGP;ENCODING=b:{}", STANDARD.encode(DER)),
|
||||
format!("KEY;TYPE=X509;ENCODING=b:{}", STANDARD.encode(PGP)),
|
||||
format!("KEY:data:application/pkix-cert;base64,{}", STANDARD.encode(PGP)),
|
||||
format!("KEY;MEDIATYPE=application/pgp-keys:{}", escaped(&pem(DER))),
|
||||
] {
|
||||
assert!(keys("4.0", &line).is_empty(), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_key_is_dropped() {
|
||||
for junk in [
|
||||
"KEY;TYPE=PGP;ENCODING=b:!!!",
|
||||
"KEY;TYPE=PGP;ENCODING=b:",
|
||||
"KEY;TYPE=X509:",
|
||||
"KEY:javascript:alert(1)",
|
||||
"KEY:file:///etc/passwd",
|
||||
"KEY:cid:part1@example.org",
|
||||
"KEY;VALUE=uri:https://exa mple.org/key",
|
||||
"KEY:https:",
|
||||
"KEY:no scheme at all",
|
||||
"KEY:data:application/pgp-keys;base64",
|
||||
"KEY;TYPE=X509:-----BEGIN CERTIFICATE-----\\n!!!\\n-----END CERTIFICATE-----",
|
||||
"KEY;TYPE=X509:-----BEGIN CERTIFICATE-----\\nMAA=",
|
||||
"KEY;TYPE=X-SOMETHING;ENCODING=b:AAEC",
|
||||
// A packet that runs past the end, a partial length, a header that is none.
|
||||
"KEY;ENCODING=b:mQAJBA==",
|
||||
"KEY;ENCODING=b:xuAA",
|
||||
"KEY;ENCODING=b:BgAA",
|
||||
] {
|
||||
assert!(keys("3.0", junk).is_empty(), "{junk}");
|
||||
}
|
||||
let long = format!("KEY:https://example.org/{}", "a".repeat(ContactKey::MAX_URI));
|
||||
assert!(keys("4.0", &long).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_with_format_characters_is_dropped() {
|
||||
for hidden in ['\u{202e}', '\u{200b}', '\u{2066}', '\u{feff}', '\u{ad}'] {
|
||||
assert!(keys("4.0", &format!("KEY:https://keys.example.org/{hidden}ada.asc")).is_empty(), "{:?}", hidden);
|
||||
}
|
||||
assert_eq!(keys("4.0", "KEY:https://keys.example.org/ada.asc"), [at("https://keys.example.org/ada.asc", "")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_counted_is_the_keys_own() {
|
||||
// A quoted-printable certificate just under the cap is three times as long written, and kept.
|
||||
let big = certificate(&[1], &vec![0; ContactKey::MAX_BYTES - 20]);
|
||||
assert!(big.len() <= ContactKey::MAX_BYTES && big.len() > ContactKey::MAX_BYTES - 32);
|
||||
let quoted: String = big.iter().map(|b| format!("={b:02X}")).collect();
|
||||
assert_eq!(keys("2.1", &format!("KEY;X509;QUOTED-PRINTABLE:{quoted}")), [x509(&big)]);
|
||||
let percent: String = big.iter().map(|b| format!("%{b:02X}")).collect();
|
||||
assert_eq!(keys("4.0", &format!("KEY:data:application/pkix-cert,{percent}")), [x509(&big)]);
|
||||
// One byte more is not.
|
||||
let over = certificate(&[1], &vec![0; ContactKey::MAX_BYTES - 20 + (ContactKey::MAX_BYTES - big.len()) + 1]);
|
||||
assert_eq!(over.len(), ContactKey::MAX_BYTES + 1);
|
||||
assert!(keys("3.0", &format!("KEY;TYPE=X509;ENCODING=b:{}", STANDARD.encode(&over))).is_empty());
|
||||
let quoted: String = over.iter().map(|b| format!("={b:02X}")).collect();
|
||||
assert!(keys("2.1", &format!("KEY;X509;QUOTED-PRINTABLE:{quoted}")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_capped_per_card_and_per_text() {
|
||||
let many: String = (0..Contact::MAX_KEYS + 10)
|
||||
.map(|i| format!("KEY;TYPE=X509;ENCODING=b:{}\r\n", STANDARD.encode(certificate(&[u8::try_from(i).unwrap()], &[0]))))
|
||||
.collect();
|
||||
assert_eq!(keys("3.0", &many).len(), Contact::MAX_KEYS);
|
||||
// What one text keeps of keys in all: the cards after the allowance is spent keep none.
|
||||
let card = |n: u8| format!("BEGIN:VCARD\r\nFN:C{n}\r\nKEY;TYPE=X509;ENCODING=b:{}\r\nEND:VCARD\r\n", STANDARD.encode(certificate(&[n], &[0])));
|
||||
let text: String = (0..5).map(card).collect();
|
||||
let got = crate::read::parse_keeping(&text, 25);
|
||||
assert_eq!(got.iter().map(|c| c.keys.len()).collect::<Vec<_>>(), [1, 1, 0, 0, 0]);
|
||||
assert_eq!(parse(&text).iter().map(|c| c.keys.len()).sum::<usize>(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_of_nothing_but_keys_is_skipped() {
|
||||
let only = format!(
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nKEY;TYPE=X509;ENCODING=b:{}\r\nKEY:https://keys.example.org/a.asc\r\nEND:VCARD\r\n",
|
||||
STANDARD.encode(DER)
|
||||
);
|
||||
assert!(parse(&only).is_empty());
|
||||
assert_eq!(parse(&only.replace("VERSION:3.0", "VERSION:3.0\r\nFN:Ada"))[0].keys.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_keys_as_3_0() {
|
||||
let c = Contact {
|
||||
display_name: "Ada".into(),
|
||||
keys: vec![
|
||||
x509(DER),
|
||||
openpgp(PGP),
|
||||
openpgp(armoured(PGP).as_bytes()),
|
||||
// Never written: addresses (3.0 has no form for them), nothing in it, bytes that aren't the kind they say.
|
||||
at("https://keys.example.org/ada.asc", ContactKey::OPENPGP_MEDIA_TYPE),
|
||||
at("openpgp4fpr:ABCDEF01", ""),
|
||||
openpgp(&[]),
|
||||
openpgp(DER),
|
||||
ContactKey { uri: "https://example.org/a".into(), ..x509(DER) },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let out = write(&[&c]);
|
||||
let lines: Vec<&str> = out.split("\r\n").filter(|l| l.starts_with("KEY")).collect();
|
||||
assert_eq!(lines.len(), 3, "{out}");
|
||||
assert_eq!(lines[0], format!("KEY;TYPE=X509;ENCODING=b:{}", STANDARD.encode(DER)));
|
||||
assert_eq!(lines[1], format!("KEY;TYPE=PGP;ENCODING=b:{}", STANDARD.encode(PGP)));
|
||||
assert!(lines[2].starts_with("KEY;TYPE=PGP;ENCODING=b:"));
|
||||
assert!(!out.contains("VALUE=uri") && !out.contains("keys.example.org"));
|
||||
assert_eq!(parse(&out)[0].keys, c.keys[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_keys_fold_and_read_back() {
|
||||
let signature: Vec<u8> = (0..ContactKey::MAX_BYTES - 32).map(|i| (i % 251) as u8).collect();
|
||||
let cert = certificate(&[1], &signature);
|
||||
let body: Vec<u8> = (0..60_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let pgp = [&[0x9a, 0x00, 0x00, 0xea, 0x60][..], &body].concat();
|
||||
let c = Contact { display_name: "Ada".into(), keys: vec![x509(&cert), openpgp(&pgp)], ..Default::default() };
|
||||
let out = write(&[&c]);
|
||||
assert!(out.split("\r\n").all(|line| line.len() <= 75));
|
||||
assert_eq!(parse(&out)[0].keys, c.keys);
|
||||
assert_eq!(crate::parse_bytes(out.as_bytes())[0].keys, c.keys);
|
||||
}
|
||||
|
||||
/// A certificate's outline or an OpenPGP public key (binary or armoured), from random bytes.
|
||||
fn key() -> impl Strategy<Value = ContactKey> {
|
||||
let bytes = || proptest::collection::vec(any::<u8>(), 1..200);
|
||||
let user_ids = proptest::collection::vec(proptest::collection::vec(any::<u8>(), 0..60), 0..3);
|
||||
let packets = (bytes(), user_ids).prop_map(|(body, ids)| {
|
||||
let mut out = vec![0x99, 0, body.len() as u8];
|
||||
out.extend(body);
|
||||
for id in ids {
|
||||
// An old-format user ID packet (tag 13) with a one-byte length.
|
||||
out.extend([0xb4, id.len() as u8]);
|
||||
out.extend(id);
|
||||
}
|
||||
out
|
||||
});
|
||||
prop_oneof![
|
||||
(bytes(), bytes()).prop_map(|(serial, signature)| x509(&certificate(&serial[..serial.len().min(100)], &signature))),
|
||||
packets.clone().prop_map(|p| openpgp(&p)),
|
||||
packets.prop_map(|p| openpgp(armoured(&p).as_bytes())),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn any_keys_round_trip(list in proptest::collection::vec(key(), 0..6)) {
|
||||
let mut unique: Vec<ContactKey> = Vec::new();
|
||||
for k in list {
|
||||
if !unique.contains(&k) {
|
||||
unique.push(k);
|
||||
}
|
||||
}
|
||||
let c = Contact { display_name: "Ada".into(), keys: unique, ..Default::default() };
|
||||
let back = parse(&write(&[&c]));
|
||||
prop_assert_eq!(back.len(), 1);
|
||||
prop_assert_eq!(&back[0].keys, &c.keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_key_values_never_panic(
|
||||
params in proptest::sample::select(vec!["", ";ENCODING=b", ";TYPE=PGP", ";TYPE=X509;ENCODING=BASE64", ";X509;QUOTED-PRINTABLE", ";MEDIATYPE=application/pgp-keys", ";VALUE=text", ";VALUE=uri"]),
|
||||
head in proptest::sample::select(vec!["", "data:", "data:application/pkix-cert;base64,", "data:,%", "-----BEGIN CERTIFICATE-----", "-----BEGIN PGP PUBLIC KEY BLOCK-----", "https:", "openpgp4fpr:", "0", "\u{99}"]),
|
||||
value in any::<String>(),
|
||||
bytes in proptest::collection::vec(any::<u8>(), 0..512),
|
||||
) {
|
||||
let text = format!("BEGIN:VCARD\r\nFN:x\r\nKEY{params}:{head}{value}\r\nKEY{params}:{head}{}\r\nEND:VCARD\r\n", STANDARD.encode(&bytes));
|
||||
let got = parse(&text);
|
||||
for c in &got {
|
||||
prop_assert!(c.keys.iter().all(|k| k.data.len() <= ContactKey::MAX_BYTES && k.uri.len() <= ContactKey::MAX_URI));
|
||||
// What was read writes and reads back as itself (addresses are not written to 3.0).
|
||||
let again = parse(&write(&[c]));
|
||||
let written: Vec<&ContactKey> = c.keys.iter().filter(|k| k.kind != KeyKind::Uri).collect();
|
||||
prop_assert_eq!(again.first().map(|a| a.keys.iter().collect::<Vec<_>>()), Some(written));
|
||||
}
|
||||
let mut raw = format!("BEGIN:VCARD\r\nFN:x\r\nKEY{params}:{head}").into_bytes();
|
||||
raw.extend(&bytes);
|
||||
raw.extend(b"\r\nEND:VCARD\r\n");
|
||||
let _ = crate::parse_bytes(&raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_are_never_kept_as_secret_material(body in proptest::collection::vec(any::<u8>(), 0..300)) {
|
||||
// Whatever follows a secret-key or secret-subkey header, nothing is kept.
|
||||
for header in [[0x95u8, 0x00], [0x94, 0x00], [0x9d, 0x00], [0xc5, 0x00], [0xc7, 0x00]] {
|
||||
let mut packet = header.to_vec();
|
||||
if let Some(len) = packet.get_mut(1) {
|
||||
*len = body.len().min(150) as u8;
|
||||
}
|
||||
let mut bytes = packet;
|
||||
bytes.extend(body.iter().take(150));
|
||||
let line = format!("KEY;TYPE=PGP;ENCODING=b:{}", STANDARD.encode([PGP, &bytes].concat()));
|
||||
prop_assert!(keys("3.0", &line).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! vCard: contacts as vCard text (`.vcf` files, CardDAV address data).
|
||||
//!
|
||||
//! [`parse`] reads vCard 2.1, 3.0 and 4.0, one card or many in a text ([`parse_bytes`] in a file,
|
||||
//! each value in its own `CHARSET`); [`write()`] writes vCard 3.0, the version every address book
|
||||
//! reads. Both work on [`Contact`], this crate's own plain model of a card.
|
||||
//!
|
||||
//! Input is hostile. Nothing here panics on any text: line lengths and counts are capped, and what
|
||||
//! makes no sense is skipped.
|
||||
|
||||
mod key;
|
||||
#[cfg(test)]
|
||||
mod key_tests;
|
||||
mod lines;
|
||||
mod model;
|
||||
mod read;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
mod write;
|
||||
|
||||
pub use model::{Contact, ContactKey, KeyKind, Labeled, PostalAddress};
|
||||
pub use read::{parse, parse_bytes};
|
||||
pub use write::write;
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Content lines: unfolding (and quoted-printable soft line breaks) on the way in, folding on the
|
||||
//! way out, and the `group.NAME;PARAM=value;PARAM:value` shape in between.
|
||||
|
||||
/// Longest logical line kept, in bytes; the rest of a longer line is dropped.
|
||||
pub(crate) const MAX_LINE: usize = 1 << 20;
|
||||
/// Most parameters read from one line, and most values of one parameter.
|
||||
const MAX_PARAMS: usize = 64;
|
||||
/// Octets per physical line when writing.
|
||||
const FOLD_AT: usize = 75;
|
||||
/// How far into a line the `:` that ends its parameters is looked for.
|
||||
const MAX_HEAD: usize = 1024;
|
||||
|
||||
/// The longest prefix of `s` of at most `max` bytes that ends on a char boundary.
|
||||
pub(crate) fn prefix(s: &str, max: usize) -> &str {
|
||||
if s.len() <= max {
|
||||
return s;
|
||||
}
|
||||
let mut end = max;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s.get(..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The text of a vCard file from its bytes, each property's value read in the `CHARSET` it names.
|
||||
///
|
||||
/// vCard 2.1 lets a value be raw 8-bit bytes in a character set of its own
|
||||
/// (`FN;CHARSET=WINDOWS-1250:Gvo\xF0\xE6i\xE8`), so a file is read line by line: a value with
|
||||
/// bytes above 0x7F is decoded with the `CHARSET` of its property (a folded line goes on with the
|
||||
/// property above it), and without one it is UTF-8 when it is valid UTF-8, else Windows-1252. Names,
|
||||
/// parameters and values in ASCII (quoted-printable and base64 among them, decoded later with
|
||||
/// their charset) are kept as they are. A file that opens with a UTF-16 byte order mark is read as
|
||||
/// UTF-16 throughout.
|
||||
pub(crate) fn text_from_bytes(bytes: &[u8]) -> String {
|
||||
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
|
||||
return craft_mime::charset::decode(bytes, "utf-16");
|
||||
}
|
||||
let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
|
||||
if bytes.is_ascii() {
|
||||
return String::from_utf8_lossy(bytes).into_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
// The charset of the property being read, for the folded lines that go on with it.
|
||||
let mut charset = String::new();
|
||||
let mut rest = bytes;
|
||||
while !rest.is_empty() {
|
||||
let end = rest.iter().position(|b| matches!(b, b'\r' | b'\n')).unwrap_or(rest.len());
|
||||
let skip = match (rest.get(end), rest.get(end.saturating_add(1))) {
|
||||
(Some(b'\r'), Some(b'\n')) => 2,
|
||||
(Some(_), _) => 1,
|
||||
(None, _) => 0,
|
||||
};
|
||||
let line = rest.get(..end).unwrap_or(&[]);
|
||||
let value = if line.starts_with(b" ") || line.starts_with(b"\t") {
|
||||
line
|
||||
} else if let Some(colon) = head_end(line) {
|
||||
let head = line.get(..colon).unwrap_or(&[]);
|
||||
charset = charset_of(head);
|
||||
out.push_str(&String::from_utf8_lossy(head));
|
||||
line.get(colon..).unwrap_or(&[])
|
||||
} else {
|
||||
charset.clear();
|
||||
line
|
||||
};
|
||||
out.push_str(&value_text(value, &charset));
|
||||
// The line break itself, as it was.
|
||||
out.push_str(&String::from_utf8_lossy(rest.get(end..end.saturating_add(skip)).unwrap_or(&[])));
|
||||
rest = rest.get(end.saturating_add(skip)..).unwrap_or(&[]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where the `:` that ends a line's name and parameters is: outside quotes, in the first kilobyte.
|
||||
fn head_end(line: &[u8]) -> Option<usize> {
|
||||
let mut quoted = false;
|
||||
for (i, b) in line.iter().enumerate().take(MAX_HEAD) {
|
||||
match b {
|
||||
b'"' => quoted = !quoted,
|
||||
b':' if !quoted => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `CHARSET` named among a line's parameters; empty when there is none.
|
||||
fn charset_of(head: &[u8]) -> String {
|
||||
let head = String::from_utf8_lossy(head);
|
||||
let named = head.split(';').skip(1).find_map(|param| {
|
||||
let (name, value) = param.split_once('=')?;
|
||||
name.trim().eq_ignore_ascii_case("CHARSET").then(|| value.trim().trim_matches('"').trim().to_string())
|
||||
});
|
||||
named.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A value's bytes as text in `charset`, or guessed without one. ASCII stays as it is; a set that
|
||||
/// ASCII isn't part of (UTF-16, ISO-2022) can't be raw 8-bit bytes on a line, so it is guessed.
|
||||
fn value_text(value: &[u8], charset: &str) -> String {
|
||||
let label = charset.to_ascii_lowercase();
|
||||
if value.is_ascii() || label.starts_with("utf-16") || label.contains("2022") {
|
||||
return craft_mime::charset::guess(value);
|
||||
}
|
||||
craft_mime::charset::decode(value, &label)
|
||||
}
|
||||
|
||||
/// A cursor over the lines of a text. Lines end at CRLF, LF or CR.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Lines<'a> {
|
||||
rest: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Lines<'a> {
|
||||
pub(crate) fn new(text: &'a str) -> Lines<'a> {
|
||||
Lines { rest: text.strip_prefix('\u{feff}').unwrap_or(text) }
|
||||
}
|
||||
|
||||
/// The next physical line, without its line break.
|
||||
fn physical(&mut self) -> Option<&'a str> {
|
||||
if self.rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes = self.rest.as_bytes();
|
||||
let end = bytes.iter().position(|b| matches!(b, b'\r' | b'\n')).unwrap_or(bytes.len());
|
||||
let skip = match (bytes.get(end), bytes.get(end.saturating_add(1))) {
|
||||
(Some(b'\r'), Some(b'\n')) => 2,
|
||||
(Some(_), _) => 1,
|
||||
(None, _) => 0,
|
||||
};
|
||||
let line = self.rest.get(..end).unwrap_or("");
|
||||
self.rest = self.rest.get(end.saturating_add(skip)..).unwrap_or("");
|
||||
Some(line)
|
||||
}
|
||||
|
||||
/// The next logical line, or `None` at the end of the text.
|
||||
///
|
||||
/// A line that starts with a space or a tab continues the one before. vCard 3.0 and 4.0 drop
|
||||
/// that first character; vCard 2.1 (`v21`) keeps it, since there a line is folded where it has
|
||||
/// white space anyway. A quoted-printable value that ends in `=` goes on with the next line,
|
||||
/// whatever that starts with.
|
||||
pub(crate) fn next_line(&mut self, v21: bool) -> Option<String> {
|
||||
let mut line = String::new();
|
||||
let mut last = self.physical()?;
|
||||
// Whether `line` holds all that was read; a line past the cap is still read to its end.
|
||||
let mut whole = push_capped(&mut line, last);
|
||||
let mut quoted_printable = None;
|
||||
loop {
|
||||
if quoted_printable.is_none() {
|
||||
quoted_printable = says_quoted_printable(&line);
|
||||
}
|
||||
let mut ahead = *self;
|
||||
let Some(next) = ahead.physical() else { break };
|
||||
if quoted_printable == Some(true) && last.trim_end().ends_with('=') && !starts_or_ends_a_card(next) {
|
||||
if whole {
|
||||
line.truncate(line.trim_end().len());
|
||||
line.pop();
|
||||
}
|
||||
whole &= push_capped(&mut line, next);
|
||||
} else if next.starts_with([' ', '\t']) {
|
||||
whole &= push_capped(&mut line, if v21 { next } else { next.get(1..).unwrap_or("") });
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
last = next;
|
||||
*self = ahead;
|
||||
}
|
||||
Some(line)
|
||||
}
|
||||
}
|
||||
|
||||
/// Append as much of `more` as the cap leaves room for; whether that was all of it.
|
||||
fn push_capped(line: &mut String, more: &str) -> bool {
|
||||
let piece = prefix(more, MAX_LINE.saturating_sub(line.len()));
|
||||
line.push_str(piece);
|
||||
piece.len() == more.len()
|
||||
}
|
||||
|
||||
/// Whether the parameters of `line` name the quoted-printable encoding; `None` while the `:` that
|
||||
/// ends them hasn't been seen yet.
|
||||
fn says_quoted_printable(line: &str) -> Option<bool> {
|
||||
let head = prefix(line, MAX_HEAD);
|
||||
match head.find(':') {
|
||||
Some(colon) => Some(head.get(..colon).unwrap_or("").to_ascii_uppercase().contains("QUOTED-PRINTABLE")),
|
||||
None if line.len() > MAX_HEAD => Some(false),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A soft line break never swallows the line that ends the card.
|
||||
fn starts_or_ends_a_card(line: &str) -> bool {
|
||||
let line = line.trim_start();
|
||||
let is = |word: &str| line.get(..word.len()).is_some_and(|start| start.eq_ignore_ascii_case(word));
|
||||
is("END:") || is("BEGIN:")
|
||||
}
|
||||
|
||||
/// One property parameter: `TYPE=WORK,VOICE`, `ENCODING=QUOTED-PRINTABLE`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Param {
|
||||
/// Upper-cased.
|
||||
pub name: String,
|
||||
pub values: Vec<String>,
|
||||
}
|
||||
|
||||
/// One property: `item1.TEL;TYPE=CELL:+1 555 0100`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ContentLine {
|
||||
/// The group before the name (`item1`), lower-cased; empty when there is none.
|
||||
pub group: String,
|
||||
/// Upper-cased, without the group.
|
||||
pub name: String,
|
||||
pub params: Vec<Param>,
|
||||
/// As written: still encoded and escaped.
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl ContentLine {
|
||||
/// Parse an unfolded line; `None` for a line without a name or without a `:`.
|
||||
///
|
||||
/// A parameter without a name (vCard 2.1: `TEL;WORK;VOICE:`) is a `TYPE`, or an `ENCODING` when
|
||||
/// it names one.
|
||||
pub(crate) fn parse(line: &str) -> Option<ContentLine> {
|
||||
// Every delimiter is ASCII, so the byte positions found here are char boundaries.
|
||||
let bytes = line.as_bytes();
|
||||
let stop = |from: usize, at: &[u8]| {
|
||||
bytes.get(from..).and_then(|b| b.iter().position(|c| at.contains(c))).map_or(bytes.len(), |i| from.saturating_add(i))
|
||||
};
|
||||
let mut pos = stop(0, b";:");
|
||||
let full = line.get(..pos)?.trim();
|
||||
let (group, name) = full.rsplit_once('.').unwrap_or(("", full));
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut params = Vec::new();
|
||||
while bytes.get(pos) == Some(&b';') {
|
||||
let start = pos.saturating_add(1);
|
||||
pos = stop(start, b"=;:");
|
||||
let written = line.get(start..pos)?.trim().to_ascii_uppercase();
|
||||
let param = if bytes.get(pos) == Some(&b'=') {
|
||||
let mut values = Vec::new();
|
||||
while matches!(bytes.get(pos), Some(b'=' | b',')) {
|
||||
let from = pos.saturating_add(1);
|
||||
let value = if bytes.get(from) == Some(&b'"') {
|
||||
let open = from.saturating_add(1);
|
||||
let close = stop(open, b"\"");
|
||||
// A quote that never closes: the line has no value.
|
||||
bytes.get(close)?;
|
||||
pos = stop(close, b",;:");
|
||||
line.get(open..close)?
|
||||
} else {
|
||||
pos = stop(from, b",;:");
|
||||
line.get(from..pos)?
|
||||
};
|
||||
if values.len() < MAX_PARAMS {
|
||||
values.push(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
Param { name: written, values }
|
||||
} else if matches!(written.as_str(), "QUOTED-PRINTABLE" | "BASE64" | "B" | "7BIT" | "8BIT") {
|
||||
Param { name: "ENCODING".into(), values: vec![written] }
|
||||
} else {
|
||||
Param { name: "TYPE".into(), values: vec![written] }
|
||||
};
|
||||
if !param.name.is_empty() && param.values.iter().any(|v| !v.is_empty()) && params.len() < MAX_PARAMS {
|
||||
params.push(param);
|
||||
}
|
||||
}
|
||||
if bytes.get(pos) != Some(&b':') {
|
||||
return None;
|
||||
}
|
||||
Some(ContentLine {
|
||||
group: group.trim().to_ascii_lowercase(),
|
||||
name: name.trim().to_ascii_uppercase(),
|
||||
params,
|
||||
value: line.get(pos.saturating_add(1)..)?.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The first value of the parameter `name` (upper case).
|
||||
pub(crate) fn param(&self, name: &str) -> Option<&str> {
|
||||
self.params.iter().find(|p| p.name == name).and_then(|p| p.values.first()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Every `TYPE`, upper-cased: `TYPE=work,voice`, `TYPE=work;TYPE=voice`, `TYPE="work,voice"`
|
||||
/// and the bare `WORK;VOICE` all give `WORK` and `VOICE`.
|
||||
pub(crate) fn types(&self) -> Vec<String> {
|
||||
let values = self.params.iter().filter(|p| p.name == "TYPE").flat_map(|p| p.values.iter());
|
||||
values.flat_map(|v| v.split(',')).map(|v| v.trim().to_ascii_uppercase()).filter(|v| !v.is_empty()).take(MAX_PARAMS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `line` to `out` with CRLF, folded so no physical line is longer than 75 octets and no
|
||||
/// character is split.
|
||||
pub(crate) fn fold(out: &mut String, line: &str) {
|
||||
let mut rest = line;
|
||||
let mut room = FOLD_AT;
|
||||
while rest.len() > room {
|
||||
let head = prefix(rest, room);
|
||||
if head.is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push_str(head);
|
||||
out.push_str("\r\n ");
|
||||
rest = rest.get(head.len()..).unwrap_or("");
|
||||
// The leading space counts.
|
||||
room = FOLD_AT - 1;
|
||||
}
|
||||
out.push_str(rest);
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lines(text: &str, v21: bool) -> Vec<String> {
|
||||
let mut cursor = Lines::new(text);
|
||||
std::iter::from_fn(|| cursor.next_line(v21)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfolds_every_kind_of_line_break() {
|
||||
assert_eq!(lines("A:1\r\nB:2\nC:3\rD:4", false), ["A:1", "B:2", "C:3", "D:4"]);
|
||||
assert_eq!(lines("NOTE:Lunch \r\n with Ada\r\n", false), ["NOTE:Lunch with Ada"]);
|
||||
assert_eq!(lines("NOTE:Lun\n\tch\n twice\n", false), ["NOTE:Lunch twice"]);
|
||||
assert_eq!(lines("\u{feff}BEGIN:VCARD\r\n\r\nEND:VCARD", false), ["BEGIN:VCARD", "", "END:VCARD"]);
|
||||
assert!(lines("", false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_2_1_keeps_the_white_space_of_a_fold() {
|
||||
let text = "NOTE:This is a long\r\n description\r\n\tin three pieces\r\nFN:x";
|
||||
assert_eq!(lines(text, true), ["NOTE:This is a long description\tin three pieces", "FN:x"]);
|
||||
assert_eq!(lines(text, false), ["NOTE:This is a longdescriptionin three pieces", "FN:x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_printable_soft_breaks_join_lines() {
|
||||
let text = "NOTE;ENCODING=QUOTED-PRINTABLE:first=0D=0A=\r\nsecond =\r\n third= \r\nfourth\r\nFN:x\r\n";
|
||||
assert_eq!(lines(text, true), ["NOTE;ENCODING=QUOTED-PRINTABLE:first=0D=0Asecond thirdfourth", "FN:x"]);
|
||||
// The bare 2.1 spelling, in lower case.
|
||||
assert_eq!(lines("note;quoted-printable:a=\nb\nFN:x", false), ["note;quoted-printable:ab", "FN:x"]);
|
||||
// Without the encoding an `=` at the end is just an `=`.
|
||||
assert_eq!(lines("NOTE:1+1=\r\nFN:x", false), ["NOTE:1+1=", "FN:x"]);
|
||||
// The parameters may themselves be folded before the `:` comes.
|
||||
assert_eq!(lines("NOTE;CHARSET=UTF-8;\r\n ENCODING=QUOTED-PRINTABLE:a=\r\nb", false), ["NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:ab"]);
|
||||
// A soft break at the very end of a card doesn't eat the END line, and one at the end of the text is harmless.
|
||||
assert_eq!(lines("NOTE;QUOTED-PRINTABLE:a=\r\nEND:VCARD\r\n", false), ["NOTE;QUOTED-PRINTABLE:a=", "END:VCARD"]);
|
||||
assert_eq!(lines("NOTE;QUOTED-PRINTABLE:a=", false), ["NOTE;QUOTED-PRINTABLE:a="]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_groups_names_parameters_and_values() {
|
||||
let l = ContentLine::parse("Item1.email;type=INTERNET;Type=pref:nadia@example.org").unwrap();
|
||||
assert_eq!((l.group.as_str(), l.name.as_str(), l.value.as_str()), ("item1", "EMAIL", "nadia@example.org"));
|
||||
assert_eq!(l.types(), ["INTERNET", "PREF"]);
|
||||
let plain = ContentLine::parse("NOTE:Budget: phase two; hiring").unwrap();
|
||||
assert_eq!(
|
||||
(plain.group.as_str(), plain.name.as_str(), plain.params.len(), plain.value.as_str()),
|
||||
("", "NOTE", 0, "Budget: phase two; hiring")
|
||||
);
|
||||
assert_eq!(ContentLine::parse("FN:").unwrap().value, "");
|
||||
for bad in ["no colon here", ":value without a name", "item1.:x", ";WORK:x", "TEL;TYPE=\"never closed:1", " ", ""] {
|
||||
assert_eq!(ContentLine::parse(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_come_in_every_spelling() {
|
||||
let types = |line: &str| ContentLine::parse(line).unwrap().types();
|
||||
assert_eq!(types("TEL;TYPE=WORK,VOICE:1"), ["WORK", "VOICE"]);
|
||||
assert_eq!(types("TEL;type=work;type=voice:1"), ["WORK", "VOICE"]);
|
||||
assert_eq!(types("TEL;WORK;VOICE:1"), ["WORK", "VOICE"]);
|
||||
assert_eq!(types("TEL;VALUE=uri;TYPE=\"voice,cell\";PREF=1:tel:+1-555-0100"), ["VOICE", "CELL"]);
|
||||
assert_eq!(types("TEL;TYPE=:1").len(), 0);
|
||||
assert_eq!(types("TEL:1").len(), 0);
|
||||
let bare = ContentLine::parse("NOTE;CHARSET=utf-8;QUOTED-PRINTABLE;;X-THING:a").unwrap();
|
||||
assert_eq!(bare.param("ENCODING"), Some("QUOTED-PRINTABLE"));
|
||||
assert_eq!(bare.param("CHARSET"), Some("utf-8"));
|
||||
assert_eq!(bare.types(), ["X-THING"]);
|
||||
assert_eq!(ContentLine::parse("PHOTO;BASE64;JPEG:AAAA").unwrap().param("ENCODING"), Some("BASE64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folds_at_75_octets_without_splitting_characters() {
|
||||
let line = format!("NOTE:{}", "é東🎉x".repeat(40));
|
||||
let mut out = String::new();
|
||||
fold(&mut out, &line);
|
||||
let physical: Vec<&str> = out.trim_end_matches("\r\n").split("\r\n").collect();
|
||||
assert!(physical.len() > 3);
|
||||
assert!(physical.iter().all(|l| l.len() <= 75), "{physical:?}");
|
||||
assert!(physical.iter().skip(1).all(|l| l.starts_with(' ')));
|
||||
assert_eq!(lines(&out, false), [line]);
|
||||
let mut exact = String::new();
|
||||
fold(&mut exact, &"x".repeat(76));
|
||||
assert_eq!(exact, format!("{}\r\n x\r\n", "x".repeat(75)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enormous_lines_are_capped() {
|
||||
let text = format!("PHOTO;ENCODING=b:{}\r\n {}\r\nFN:after", "A".repeat(MAX_LINE + 5000), "B".repeat(1000));
|
||||
let got = lines(&text, false);
|
||||
assert_eq!(got.len(), 2);
|
||||
assert!(got[0].len() <= MAX_LINE);
|
||||
assert_eq!(got[1], "FN:after");
|
||||
// A quoted-printable value that never stops.
|
||||
let endless = format!("NOTE;QUOTED-PRINTABLE:{}", "é=\r\n".repeat(700_000));
|
||||
let got = lines(&endless, true);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!(got[0].len() <= MAX_LINE);
|
||||
assert_eq!(prefix("héllo", 2), "h");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn folding_then_unfolding_gives_the_line_back(line in "[^\r\n]{0,400}") {
|
||||
let mut out = String::new();
|
||||
fold(&mut out, &line);
|
||||
proptest::prop_assert!(out.split("\r\n").all(|l| l.len() <= 75));
|
||||
// A byte order mark at the very start of a text isn't part of its first line.
|
||||
proptest::prop_assert_eq!(lines(&out, false), vec![line.strip_prefix('\u{feff}').unwrap_or(&line).to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_text_splits_into_lines(text in proptest::prelude::any::<String>(), v21 in proptest::prelude::any::<bool>()) {
|
||||
for line in lines(&text, v21) {
|
||||
let _ = ContentLine::parse(&line).map(|l| l.types());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! What a card holds: a contact, as the vCard properties this crate reads and writes.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// A value with a label (`work`, `home`, `mobile`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Labeled {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Labeled {
|
||||
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Labeled {
|
||||
Labeled { label: label.into(), value: value.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A postal address (`ADR`). The street holds the post office box and the extended address too.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PostalAddress {
|
||||
pub label: String,
|
||||
pub street: String,
|
||||
pub city: String,
|
||||
pub region: String,
|
||||
pub postal_code: String,
|
||||
pub country: String,
|
||||
}
|
||||
|
||||
impl PostalAddress {
|
||||
/// The address on one line, skipping empty parts.
|
||||
pub fn one_line(&self) -> String {
|
||||
let city = [self.city.as_str(), self.region.as_str(), self.postal_code.as_str()]
|
||||
.into_iter()
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
[self.street.as_str(), city.as_str(), self.country.as_str()].into_iter().filter(|p| !p.trim().is_empty()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.one_line().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a key on a card is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum KeyKind {
|
||||
/// An S/MIME certificate (X.509, DER).
|
||||
#[default]
|
||||
X509,
|
||||
/// An OpenPGP public key, binary or ASCII-armoured, as the card had it.
|
||||
OpenPgp,
|
||||
/// Where the card says a key is ([`ContactKey::uri`]); never fetched.
|
||||
Uri,
|
||||
}
|
||||
|
||||
/// A public key or certificate a card carries (`KEY`). The reader keeps only what has the shape
|
||||
/// of a certificate or of an OpenPGP public key, never secret key material.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ContactKey {
|
||||
pub kind: KeyKind,
|
||||
/// The key's bytes; empty for a [`KeyKind::Uri`].
|
||||
pub data: Vec<u8>,
|
||||
/// For a [`KeyKind::Uri`]: the address the card gave (`https:`, `ldap:`, `openpgp4fpr:`).
|
||||
pub uri: String,
|
||||
/// For a [`KeyKind::Uri`]: what the card says is there, [`ContactKey::X509_MEDIA_TYPE`] or
|
||||
/// [`ContactKey::OPENPGP_MEDIA_TYPE`]; empty when it doesn't say.
|
||||
pub media_type: String,
|
||||
}
|
||||
|
||||
impl ContactKey {
|
||||
/// Most bytes of one key kept.
|
||||
pub const MAX_BYTES: usize = 64 * 1024;
|
||||
/// Longest address of a key kept, in bytes.
|
||||
pub const MAX_URI: usize = 2048;
|
||||
/// The media type of an S/MIME certificate.
|
||||
pub const X509_MEDIA_TYPE: &str = "application/pkix-cert";
|
||||
/// The media type of OpenPGP keys.
|
||||
pub const OPENPGP_MEDIA_TYPE: &str = "application/pgp-keys";
|
||||
}
|
||||
|
||||
/// One card.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Contact {
|
||||
/// `FN`; when empty, [`Contact::name`] builds one.
|
||||
pub display_name: String,
|
||||
pub first: String,
|
||||
pub middle: String,
|
||||
pub last: String,
|
||||
pub nickname: String,
|
||||
pub company: String,
|
||||
pub title: String,
|
||||
pub department: String,
|
||||
pub emails: Vec<Labeled>,
|
||||
pub phones: Vec<Labeled>,
|
||||
pub addresses: Vec<PostalAddress>,
|
||||
pub websites: Vec<String>,
|
||||
pub birthday: Option<NaiveDate>,
|
||||
pub notes: String,
|
||||
pub categories: Vec<String>,
|
||||
/// `X-FAVORITE` (and the forms other address books write it in).
|
||||
pub favorite: bool,
|
||||
/// `KEY`: certificates and OpenPGP public keys, at most [`Contact::MAX_KEYS`].
|
||||
pub keys: Vec<ContactKey>,
|
||||
/// `UID`, kept so a written card updates the same contact elsewhere.
|
||||
pub uid: String,
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
/// Most keys kept for one contact.
|
||||
pub const MAX_KEYS: usize = 32;
|
||||
|
||||
/// The name to show: the display name, else first and last, the company, or the first address.
|
||||
pub fn name(&self) -> String {
|
||||
if !self.display_name.trim().is_empty() {
|
||||
return self.display_name.trim().to_string();
|
||||
}
|
||||
let full = [self.first.trim(), self.middle.trim(), self.last.trim()].into_iter().filter(|p| !p.is_empty()).collect::<Vec<_>>().join(" ");
|
||||
if !full.is_empty() {
|
||||
return full;
|
||||
}
|
||||
if !self.company.trim().is_empty() {
|
||||
return self.company.trim().to_string();
|
||||
}
|
||||
self.emails.first().map(|e| e.value.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
//! Reading vCards (2.1, 3.0 and 4.0) into contacts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Contact, Labeled, PostalAddress};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::lines::{ContentLine, Lines};
|
||||
use crate::text::{base64_bytes, component, multi, quoted_printable, single, split, unescape};
|
||||
|
||||
/// Most cards read from one text.
|
||||
const MAX_CARDS: usize = 100_000;
|
||||
/// Most e-mail addresses, phone numbers, postal addresses or web sites kept per card.
|
||||
const MAX_ITEMS: usize = 100;
|
||||
const MAX_CATEGORIES: usize = 500;
|
||||
/// Most group labels (`item1.X-ABLabel`) kept per card.
|
||||
const MAX_LABELS: usize = 400;
|
||||
/// Deepest nesting of cards followed (a 2.1 `AGENT` holds a card of its own).
|
||||
const MAX_DEPTH: usize = 8;
|
||||
/// Most bytes of keys (`KEY`) kept from one text, all its cards together.
|
||||
pub(crate) const MAX_KEY_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Parse one or many vCards (2.1, 3.0, 4.0). Never fails: junk gives an empty list, and a card
|
||||
/// with nothing in it is skipped. Ids are left 0.
|
||||
///
|
||||
/// `FN` gives the display name; without one it stays empty and the model derives a name. A label
|
||||
/// comes from `TYPE` (`work`, `home`, `other`, and for phones `mobile`, `fax`, `pager`), or from the
|
||||
/// `X-ABLabel` of the property's group (`item1.X-ABLabel:Family`); it is empty when neither says
|
||||
/// anything. An S/MIME certificate or OpenPGP public key (`KEY`) is kept as bytes when its bytes
|
||||
/// have the shape of one, and the address of one as an address, never fetched; secret key
|
||||
/// material is never kept (`crate::key`). A card with nothing but keys is skipped. Photos, logos,
|
||||
/// sounds and unknown properties are skipped.
|
||||
///
|
||||
/// Work is bounded: at most 100 000 cards are read, 100 e-mail addresses, phone numbers, postal
|
||||
/// addresses and web sites per card, 32 keys of at most 64 KiB per card and 64 MiB of keys in all,
|
||||
/// and what a line holds beyond 1 MiB is dropped.
|
||||
///
|
||||
/// A file's bytes go through [`parse_bytes`], which reads raw 8-bit values in their `CHARSET`.
|
||||
pub fn parse(text: &str) -> Vec<Contact> {
|
||||
parse_keeping(text, MAX_KEY_BYTES)
|
||||
}
|
||||
|
||||
/// [`parse`], keeping at most `key_room` bytes of keys.
|
||||
pub(crate) fn parse_keeping(text: &str, mut key_room: usize) -> Vec<Contact> {
|
||||
let mut lines = Lines::new(text);
|
||||
// The cards that are open, outermost first, and the finished ones with the order they began in.
|
||||
let mut open: Vec<Card> = Vec::new();
|
||||
let mut done: Vec<(usize, Contact)> = Vec::new();
|
||||
let mut begun = 0usize;
|
||||
while done.len() < MAX_CARDS {
|
||||
let v21 = open.last().is_some_and(|card| card.v21);
|
||||
let Some(raw) = lines.next_line(v21) else { break };
|
||||
let Some(line) = ContentLine::parse(&raw) else { continue };
|
||||
let is_card = line.value.trim().eq_ignore_ascii_case("VCARD");
|
||||
match (line.name.as_str(), open.last_mut()) {
|
||||
("BEGIN", _) if is_card => {
|
||||
// A card that never ended ends where too many are open.
|
||||
if open.len() >= MAX_DEPTH {
|
||||
done.extend(open.pop().and_then(Card::finish));
|
||||
}
|
||||
open.push(Card { order: begun, ..Default::default() });
|
||||
begun = begun.saturating_add(1);
|
||||
}
|
||||
("END", _) if is_card => done.extend(open.pop().and_then(Card::finish)),
|
||||
("BEGIN", Some(card)) => card.foreign = card.foreign.saturating_add(1),
|
||||
("END", Some(card)) => card.foreign = card.foreign.saturating_sub(1),
|
||||
(_, Some(card)) if card.foreign == 0 => card.apply(&line, &mut key_room),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Cards without an END are still cards.
|
||||
while let Some(card) = open.pop() {
|
||||
done.extend(card.finish());
|
||||
}
|
||||
done.sort_by_key(|(order, _)| *order);
|
||||
done.into_iter().map(|(_, contact)| contact).take(MAX_CARDS).collect()
|
||||
}
|
||||
|
||||
/// Parse a vCard file as [`parse`] does, from its bytes: a value written as raw 8-bit bytes
|
||||
/// (vCard 2.1, `FN;CHARSET=WINDOWS-1250:…`) is read in the `CHARSET` of its property, and one
|
||||
/// without a `CHARSET` as UTF-8 when it is valid UTF-8, else as Windows-1252.
|
||||
pub fn parse_bytes(bytes: &[u8]) -> Vec<Contact> {
|
||||
parse(&crate::lines::text_from_bytes(bytes))
|
||||
}
|
||||
|
||||
/// A card being read.
|
||||
#[derive(Default)]
|
||||
struct Card {
|
||||
/// Its place among the cards of the text.
|
||||
order: usize,
|
||||
/// `VERSION:2.1`: other folding, no comma lists.
|
||||
v21: bool,
|
||||
/// How deep inside a component that isn't a card the reader is.
|
||||
foreign: u32,
|
||||
contact: Contact,
|
||||
named: bool,
|
||||
/// E-mail addresses, phones and postal addresses, each with the group it was written in.
|
||||
emails: Vec<(String, Labeled)>,
|
||||
phones: Vec<(String, Labeled)>,
|
||||
addresses: Vec<(String, PostalAddress)>,
|
||||
/// `X-ABLabel` by group.
|
||||
labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Take in one property; `key_room` is what keys may still take of the text's allowance.
|
||||
fn apply(&mut self, line: &ContentLine, key_room: &mut usize) {
|
||||
let v21 = self.v21;
|
||||
let contact = &mut self.contact;
|
||||
// One line of text, decoded and unescaped.
|
||||
let one = |line: &ContentLine| single(&unescape(&decoded(line)));
|
||||
match line.name.as_str() {
|
||||
"VERSION" => self.v21 = line.value.trim().starts_with("2."),
|
||||
"FN" if contact.display_name.is_empty() => contact.display_name = one(line),
|
||||
"N" if !self.named => {
|
||||
self.named = true;
|
||||
let value = decoded(line);
|
||||
let parts = split(&value, ';', 8);
|
||||
let part = |i: usize| parts.get(i).map(|p| component(p, " ", v21)).unwrap_or_default();
|
||||
(contact.last, contact.first, contact.middle) = (part(0), part(1), part(2));
|
||||
}
|
||||
"NICKNAME" if contact.nickname.is_empty() => contact.nickname = one(line),
|
||||
"ORG" if contact.company.is_empty() && contact.department.is_empty() => {
|
||||
let value = decoded(line);
|
||||
let parts = split(&value, ';', 16);
|
||||
contact.company = parts.first().map(|p| component(p, ", ", v21)).unwrap_or_default();
|
||||
contact.department = parts.iter().skip(1).map(|p| component(p, ", ", v21)).filter(|p| !p.is_empty()).collect::<Vec<_>>().join(", ");
|
||||
}
|
||||
"TITLE" if contact.title.is_empty() => contact.title = one(line),
|
||||
"EMAIL" if self.emails.len() < MAX_ITEMS => {
|
||||
let value = without_scheme(&one(line), "mailto:");
|
||||
if !value.is_empty() {
|
||||
self.emails.push((line.group.clone(), Labeled::new(place_label(&line.types()), value)));
|
||||
}
|
||||
}
|
||||
"TEL" if self.phones.len() < MAX_ITEMS => {
|
||||
let value = without_scheme(&one(line), "tel:");
|
||||
if !value.is_empty() {
|
||||
self.phones.push((line.group.clone(), Labeled::new(phone_label(&line.types()), value)));
|
||||
}
|
||||
}
|
||||
"ADR" if self.addresses.len() < MAX_ITEMS => {
|
||||
let value = decoded(line);
|
||||
let parts = split(&value, ';', 8);
|
||||
let part = |i: usize| parts.get(i).map(|p| component(p, ", ", v21)).unwrap_or_default();
|
||||
// Post office box, extended address, street: the lines above the city.
|
||||
let street = [part(0), part(1), part(2)].into_iter().filter(|p| !p.is_empty()).collect::<Vec<_>>().join(", ");
|
||||
let address = PostalAddress {
|
||||
label: place_label(&line.types()).into(),
|
||||
street,
|
||||
city: part(3),
|
||||
region: part(4),
|
||||
postal_code: part(5),
|
||||
country: part(6),
|
||||
};
|
||||
if !address.is_empty() {
|
||||
self.addresses.push((line.group.clone(), address));
|
||||
}
|
||||
}
|
||||
"URL" if contact.websites.len() < MAX_ITEMS => {
|
||||
let value = one(line);
|
||||
if !value.is_empty() {
|
||||
contact.websites.push(value);
|
||||
}
|
||||
}
|
||||
"BDAY" if contact.birthday.is_none() => contact.birthday = birthday(&line.value),
|
||||
"NOTE" => {
|
||||
let note = multi(&unescape(&decoded(line)));
|
||||
if !note.is_empty() {
|
||||
if !contact.notes.is_empty() {
|
||||
contact.notes.push('\n');
|
||||
}
|
||||
contact.notes.push_str(¬e);
|
||||
}
|
||||
}
|
||||
"CATEGORIES" => {
|
||||
let value = decoded(line);
|
||||
let room = MAX_CATEGORIES.saturating_sub(contact.categories.len());
|
||||
contact
|
||||
.categories
|
||||
.extend(split(&value, ',', MAX_CATEGORIES).into_iter().map(|c| single(&unescape(c))).filter(|c| !c.is_empty()).take(room));
|
||||
}
|
||||
"UID" if contact.uid.is_empty() => contact.uid = one(line),
|
||||
"KEY" if contact.keys.len() < Contact::MAX_KEYS && *key_room > 0 => {
|
||||
if let Some(key) = crate::key::read(line) {
|
||||
let cost = key.data.len().saturating_add(key.uri.len());
|
||||
if cost <= *key_room && !contact.keys.contains(&key) {
|
||||
*key_room = key_room.saturating_sub(cost);
|
||||
contact.keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
"X-FAVORITE" | "X-MAILCRAFT-FAVORITE" => {
|
||||
contact.favorite |= matches!(line.value.trim().to_ascii_uppercase().as_str(), "TRUE" | "YES" | "1")
|
||||
}
|
||||
"X-ABLABEL" if !line.group.is_empty() && self.labels.len() < MAX_LABELS => {
|
||||
let label = one(line);
|
||||
// The built-in labels of the program that invented this are wrapped: `_$!<Work>!$_`.
|
||||
let label = label.strip_prefix("_$!<").and_then(|l| l.strip_suffix(">!$_")).map(str::to_lowercase).unwrap_or(label);
|
||||
self.labels.entry(line.group.clone()).or_insert(label);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The contact with the place it began at; `None` for a card with nothing in it.
|
||||
fn finish(self) -> Option<(usize, Contact)> {
|
||||
let Card { order, mut contact, emails, phones, addresses, labels, .. } = self;
|
||||
// A label given to a group says more than the property's type.
|
||||
let label = |group: &str, own: String| labels.get(group).filter(|_| !group.is_empty()).cloned().unwrap_or(own);
|
||||
contact.emails = emails.into_iter().map(|(group, e)| Labeled { label: label(&group, e.label), value: e.value }).collect();
|
||||
contact.phones = phones.into_iter().map(|(group, p)| Labeled { label: label(&group, p.label), value: p.value }).collect();
|
||||
contact.addresses = addresses.into_iter().map(|(group, a)| PostalAddress { label: label(&group, a.label.clone()), ..a }).collect();
|
||||
// Keys alone make no contact: a card of nothing but keys is skipped as an empty one is.
|
||||
let keys = std::mem::take(&mut contact.keys);
|
||||
let empty = contact == Contact::default();
|
||||
contact.keys = keys;
|
||||
(!empty).then_some((order, contact))
|
||||
}
|
||||
}
|
||||
|
||||
/// The value with its `ENCODING` (quoted-printable or base64) and `CHARSET` undone; still escaped.
|
||||
fn decoded(line: &ContentLine) -> String {
|
||||
let bytes = match line.param("ENCODING").unwrap_or("").to_ascii_uppercase().as_str() {
|
||||
"QUOTED-PRINTABLE" | "Q" => quoted_printable(&line.value),
|
||||
"BASE64" | "B" => base64_bytes(&line.value),
|
||||
_ => return line.value.clone(),
|
||||
};
|
||||
craft_mime::charset::decode(&bytes, line.param("CHARSET").unwrap_or(""))
|
||||
}
|
||||
|
||||
/// `tel:+1-555-0100` and `mailto:a@example.org` (vCard 4.0 URIs) without the scheme.
|
||||
fn without_scheme(value: &str, scheme: &str) -> String {
|
||||
match value.get(..scheme.len()) {
|
||||
Some(start) if start.eq_ignore_ascii_case(scheme) => value.get(scheme.len()..).unwrap_or("").trim().to_string(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The label of an e-mail or postal address.
|
||||
fn place_label(types: &[String]) -> &'static str {
|
||||
let has = |t: &str| types.iter().any(|x| x == t);
|
||||
if has("WORK") {
|
||||
"work"
|
||||
} else if has("HOME") {
|
||||
"home"
|
||||
} else if has("OTHER") {
|
||||
"other"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/// The label of a phone number: what kind of device it is says more than where it stands.
|
||||
fn phone_label(types: &[String]) -> &'static str {
|
||||
let has = |t: &str| types.iter().any(|x| x == t);
|
||||
if has("CELL") || has("MOBILE") || has("IPHONE") {
|
||||
"mobile"
|
||||
} else if has("FAX") {
|
||||
"fax"
|
||||
} else if has("PAGER") {
|
||||
"pager"
|
||||
} else {
|
||||
place_label(types)
|
||||
}
|
||||
}
|
||||
|
||||
/// `1985-04-12`, `19850412`, or either with a time after it; `None` without a year (`--0412`).
|
||||
fn birthday(value: &str) -> Option<NaiveDate> {
|
||||
parse_iso(value.trim().split(['T', 't', ' ']).next()?)
|
||||
}
|
||||
|
||||
/// A date from `YYYY-MM-DD` or `YYYYMMDD`, years 1 to 9999.
|
||||
fn parse_iso(s: &str) -> Option<NaiveDate> {
|
||||
let s = s.trim();
|
||||
let digits: String = s.chars().filter(char::is_ascii_digit).collect();
|
||||
if digits.len() != 8 || s.chars().any(|c| !c.is_ascii_digit() && c != '-') {
|
||||
return None;
|
||||
}
|
||||
let year: i32 = digits.get(0..4)?.parse().ok()?;
|
||||
if year < 1 {
|
||||
return None;
|
||||
}
|
||||
NaiveDate::from_ymd_opt(year, digits.get(4..6)?.parse().ok()?, digits.get(6..8)?.parse().ok()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
/// One contact from the properties of a 3.0 card.
|
||||
fn card(properties: &str) -> Contact {
|
||||
let got = parse(&format!("BEGIN:VCARD\r\nVERSION:3.0\r\n{properties}\r\nEND:VCARD\r\n"));
|
||||
assert_eq!(got.len(), 1, "{properties}");
|
||||
got.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
/// A vCard 2.1 card as old phones and mail programs write them: bare type parameters,
|
||||
/// quoted-printable with soft line breaks, a charset.
|
||||
const V21: &str = "BEGIN:VCARD\r\n\
|
||||
VERSION:2.1\r\n\
|
||||
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.;\r\n\
|
||||
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Dr. J=C3=BCrgen M=C3=BCller\r\n\
|
||||
ORG:Beispiel Werke, GmbH;Entwicklung\r\n\
|
||||
TITLE;CHARSET=ISO-8859-1;ENCODING=QUOTED-PRINTABLE:Gesch=E4ftsf=FChrer\r\n\
|
||||
TEL;WORK;VOICE:+49 30 5550100\r\n\
|
||||
TEL;CELL:+49 170 5550101\r\n\
|
||||
TEL;WORK;FAX:+49 30 5550199\r\n\
|
||||
TEL;PAGER:+49 30 5550177\r\n\
|
||||
EMAIL;PREF;INTERNET:juergen.mueller@example.org\r\n\
|
||||
EMAIL;INTERNET;HOME:jm@privat.example.org\r\n\
|
||||
ADR;HOME;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:;;Gartenstra=C3=9Fe 12;K=C3=B6ln;NW;50667;Deutschland\r\n\
|
||||
LABEL;HOME;ENCODING=QUOTED-PRINTABLE:Gartenstra=C3=9Fe 12=0D=0A50667 K=C3=B6ln\r\n\
|
||||
NOTE;ENCODING=QUOTED-PRINTABLE:Erste Zeile=0D=0AZweite Zeile, mit einem sehr langen Text der =\r\n\
|
||||
umbrochen wird=3B und noch einmal =\r\n\
|
||||
umbrochen.\r\n\
|
||||
URL;WORK:http://example.org/~jm\r\n\
|
||||
BDAY:19720314\r\n\
|
||||
REV:20260901T101500Z\r\n\
|
||||
END:VCARD\r\n";
|
||||
|
||||
#[test]
|
||||
fn reads_a_2_1_card() {
|
||||
let got = parse(V21);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(
|
||||
got[0],
|
||||
Contact {
|
||||
display_name: "Dr. Jürgen Müller".into(),
|
||||
first: "Jürgen".into(),
|
||||
last: "Müller".into(),
|
||||
company: "Beispiel Werke, GmbH".into(),
|
||||
department: "Entwicklung".into(),
|
||||
title: "Geschäftsführer".into(),
|
||||
emails: vec![Labeled::new("", "juergen.mueller@example.org"), Labeled::new("home", "jm@privat.example.org")],
|
||||
phones: vec![
|
||||
Labeled::new("work", "+49 30 5550100"),
|
||||
Labeled::new("mobile", "+49 170 5550101"),
|
||||
Labeled::new("fax", "+49 30 5550199"),
|
||||
Labeled::new("pager", "+49 30 5550177")
|
||||
],
|
||||
addresses: vec![PostalAddress {
|
||||
label: "home".into(),
|
||||
street: "Gartenstraße 12".into(),
|
||||
city: "Köln".into(),
|
||||
region: "NW".into(),
|
||||
postal_code: "50667".into(),
|
||||
country: "Deutschland".into()
|
||||
}],
|
||||
websites: vec!["http://example.org/~jm".into()],
|
||||
birthday: Some(d(1972, 3, 14)),
|
||||
notes: "Erste Zeile\nZweite Zeile, mit einem sehr langen Text der umbrochen wird; und noch einmal umbrochen.".into(),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A vCard 3.0 card as address books export them: grouped properties with custom labels, a
|
||||
/// photo, folded lines.
|
||||
const V30: &str = "BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
PRODID:-//Example//Contacts 12.0//EN\r\n\
|
||||
N:Okafor;Nadia;Chidi,Rose;Dr.;\r\n\
|
||||
FN:Dr. Nadia Okafor\r\n\
|
||||
NICKNAME:Nadi\r\n\
|
||||
ORG:Example Research\\, Inc.;Field Lab;Unit 7\r\n\
|
||||
TITLE:Lead Scientist\r\n\
|
||||
item1.EMAIL;type=INTERNET;type=WORK;type=pref:nadia.okafor@example.org\r\n\
|
||||
item2.EMAIL;type=INTERNET:nadia@family.example.org\r\n\
|
||||
item2.X-ABLabel:Family\r\n\
|
||||
TEL;type=CELL;type=VOICE;type=pref:+1 (555) 010-0142\r\n\
|
||||
item3.TEL:+1 555 010 0143\r\n\
|
||||
item3.X-ABLabel:_$!<Other>!$_\r\n\
|
||||
TEL;TYPE=WORK,FAX:+1 555 010 0144\r\n\
|
||||
item4.ADR;type=WORK;type=pref:;Suite 300;41 Harbor Road;Riverton;CA;94000;USA\r\n\
|
||||
item4.X-ABADR:us\r\n\
|
||||
item5.URL;type=pref:https\\://example.org/~nadia\r\n\
|
||||
item5.X-ABLabel:_$!<HomePage>!$_\r\n\
|
||||
BDAY:1985-04-12\r\n\
|
||||
NOTE:Met at the 2024 symposium.\\nPrefers e-mail\\, not calls; see the long thread about the field s\r\n tation.\r\n\
|
||||
CATEGORIES:Research,Field Team\\, West\r\n\
|
||||
PHOTO;ENCODING=b;TYPE=JPEG:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1\r\n Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1u\r\n b3BxcnN0dXZ3eHl6e3x9fn8=\r\n\
|
||||
UID:7f1c2f6e-0001-4a5b-9c3d-000000000042\r\n\
|
||||
X-FAVORITE:TRUE\r\n\
|
||||
REV:2026-09-01T10:15:00Z\r\n\
|
||||
END:VCARD\r\n";
|
||||
|
||||
#[test]
|
||||
fn reads_a_3_0_card_with_grouped_properties() {
|
||||
let got = parse(V30);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(
|
||||
got[0],
|
||||
Contact {
|
||||
display_name: "Dr. Nadia Okafor".into(),
|
||||
first: "Nadia".into(),
|
||||
middle: "Chidi Rose".into(),
|
||||
last: "Okafor".into(),
|
||||
nickname: "Nadi".into(),
|
||||
company: "Example Research, Inc.".into(),
|
||||
department: "Field Lab, Unit 7".into(),
|
||||
title: "Lead Scientist".into(),
|
||||
emails: vec![Labeled::new("work", "nadia.okafor@example.org"), Labeled::new("Family", "nadia@family.example.org")],
|
||||
phones: vec![
|
||||
Labeled::new("mobile", "+1 (555) 010-0142"),
|
||||
Labeled::new("other", "+1 555 010 0143"),
|
||||
Labeled::new("fax", "+1 555 010 0144")
|
||||
],
|
||||
addresses: vec![PostalAddress {
|
||||
label: "work".into(),
|
||||
street: "Suite 300, 41 Harbor Road".into(),
|
||||
city: "Riverton".into(),
|
||||
region: "CA".into(),
|
||||
postal_code: "94000".into(),
|
||||
country: "USA".into()
|
||||
}],
|
||||
websites: vec!["https://example.org/~nadia".into()],
|
||||
birthday: Some(d(1985, 4, 12)),
|
||||
notes: "Met at the 2024 symposium.\nPrefers e-mail, not calls; see the long thread about the field station.".into(),
|
||||
categories: vec!["Research".into(), "Field Team, West".into()],
|
||||
favorite: true,
|
||||
uid: "7f1c2f6e-0001-4a5b-9c3d-000000000042".into(),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A vCard 4.0 card: URIs for phones, quoted type lists, no `N` needed.
|
||||
const V40: &str = "BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
KIND:individual\r\n\
|
||||
FN:Mei Tanaka\r\n\
|
||||
N:Tanaka;Mei;;;\r\n\
|
||||
EMAIL;TYPE=work;PREF=1:mei.tanaka@example.org\r\n\
|
||||
EMAIL;TYPE=home:mailto:mei@home.example.org\r\n\
|
||||
TEL;VALUE=uri;TYPE=\"voice,cell\";PREF=1:tel:+81-90-5550-0101\r\n\
|
||||
TEL;VALUE=uri;TYPE=work:tel:+81-3-5550-0102;ext=12\r\n\
|
||||
ADR;TYPE=home;LABEL=\"1-2-3 Sakura\\nMinato, Tokyo\":;;1-2-3 Sakura;Minato;Tokyo;105-0000;Japan\r\n\
|
||||
BDAY:19910704T080000Z\r\n\
|
||||
ANNIVERSARY:20150620\r\n\
|
||||
GENDER:F\r\n\
|
||||
LANG;PREF=1:ja\r\n\
|
||||
URL:https://example.org/mei\r\n\
|
||||
URL;TYPE=work:https://design.example.org/team/mei\r\n\
|
||||
NOTE:Speaks Japanese\\, English.\r\n\
|
||||
NOTE:Second note.\r\n\
|
||||
CATEGORIES:Design,Tokyo office\r\n\
|
||||
CATEGORIES:Speakers\r\n\
|
||||
UID:urn:uuid:0d5c7e0a-0000-4000-8000-0000000000a1\r\n\
|
||||
X-MAILCRAFT-FAVORITE:true\r\n\
|
||||
END:VCARD\r\n";
|
||||
|
||||
#[test]
|
||||
fn reads_a_4_0_card() {
|
||||
let got = parse(V40);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(
|
||||
got[0],
|
||||
Contact {
|
||||
display_name: "Mei Tanaka".into(),
|
||||
first: "Mei".into(),
|
||||
last: "Tanaka".into(),
|
||||
emails: vec![Labeled::new("work", "mei.tanaka@example.org"), Labeled::new("home", "mei@home.example.org")],
|
||||
phones: vec![Labeled::new("mobile", "+81-90-5550-0101"), Labeled::new("work", "+81-3-5550-0102;ext=12")],
|
||||
addresses: vec![PostalAddress {
|
||||
label: "home".into(),
|
||||
street: "1-2-3 Sakura".into(),
|
||||
city: "Minato".into(),
|
||||
region: "Tokyo".into(),
|
||||
postal_code: "105-0000".into(),
|
||||
country: "Japan".into()
|
||||
}],
|
||||
websites: vec!["https://example.org/mei".into(), "https://design.example.org/team/mei".into()],
|
||||
birthday: Some(d(1991, 7, 4)),
|
||||
notes: "Speaks Japanese, English.\nSecond note.".into(),
|
||||
categories: vec!["Design".into(), "Tokyo office".into(), "Speakers".into()],
|
||||
favorite: true,
|
||||
uid: "urn:uuid:0d5c7e0a-0000-4000-8000-0000000000a1".into(),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_many_cards_in_order() {
|
||||
let text = format!("{V21}\r\n{V30}junk between cards\r\n{V40}");
|
||||
let names: Vec<String> = parse(&text).into_iter().map(|c| c.display_name).collect();
|
||||
assert_eq!(names, ["Dr. Jürgen Müller", "Dr. Nadia Okafor", "Mei Tanaka"]);
|
||||
// LF only, lower case, no VERSION.
|
||||
let plain = parse("begin:vcard\nfn:Ada Lovelace\nemail:ada@example.org\nend:vcard\nBEGIN:VCARD\nFN:Grace Hopper\nEND:VCARD\n");
|
||||
assert_eq!(plain.len(), 2);
|
||||
assert_eq!((plain[0].display_name.as_str(), plain[0].emails[0].value.as_str()), ("Ada Lovelace", "ada@example.org"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_name_leaves_the_display_name_empty() {
|
||||
let c = card("N:Lovelace;Ada;;;\nEMAIL:ada@example.org");
|
||||
assert_eq!(c.display_name, "");
|
||||
assert_eq!(c.name(), "Ada Lovelace");
|
||||
let org = card("ORG:Analytical Engines Ltd");
|
||||
assert_eq!((org.display_name.as_str(), org.name().as_str()), ("", "Analytical Engines Ltd"));
|
||||
// The first FN, N and so on count.
|
||||
let twice = card("FN:First\nFN:Second\nN:A;B\nN:C;D\nTITLE:One\nTITLE:Two\nUID:1\nUID:2\nBDAY:2000-01-02\nBDAY:1999-01-02");
|
||||
assert_eq!(
|
||||
(twice.display_name.as_str(), twice.last.as_str(), twice.title.as_str(), twice.uid.as_str(), twice.birthday),
|
||||
("First", "A", "One", "1", Some(d(2000, 1, 2)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_come_from_types_and_groups() {
|
||||
let c = card(
|
||||
"EMAIL;TYPE=INTERNET,OTHER:o@example.org\nEMAIL;TYPE=x-custom:x@example.org\nEMAIL;TYPE=HOME,WORK:hw@example.org\n\
|
||||
TEL;TYPE=HOME,VOICE:1\nTEL;TYPE=WORK,CELL:2\nTEL;TYPE=iPhone:3\nTEL;TYPE=HOME;TYPE=FAX:4\nTEL;TYPE=OTHER:5\nTEL;TYPE=VOICE:6\nTEL:7\n\
|
||||
Item7.TEL;TYPE=CELL:8\nITEM7.X-ABLabel:Car\nitem8.TEL:9\nitem8.X-ABLabel:_$!<Mobile>!$_\nitem8.X-ABLabel:Ignored\nX-ABLabel:No group\nitem9.X-ABLabel:Nothing to label\n\
|
||||
ADR;TYPE=dom,postal,parcel:;;1 Main St;Riverton\nitem2.ADR:;;2 Side St\nitem2.X-ABLabel:Cabin",
|
||||
);
|
||||
let labels = |list: &[Labeled]| list.iter().map(|l| l.label.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(labels(&c.emails), ["other", "", "work"]);
|
||||
assert_eq!(labels(&c.phones), ["home", "mobile", "mobile", "fax", "other", "", "", "Car", "mobile"]);
|
||||
assert_eq!(
|
||||
c.addresses.iter().map(|a| (a.label.as_str(), a.street.as_str())).collect::<Vec<_>>(),
|
||||
[("", "1 Main St"), ("Cabin", "2 Side St")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_and_organisations_split_on_unescaped_separators() {
|
||||
let c = card("N:van der Berg\\; Jr.;Anna\\,Maria;Q.,R.;;\nORG:Semi\\;colon & Co;R\\&D;;Lab 2\nNICKNAME:Annie\\, Am");
|
||||
assert_eq!((c.last.as_str(), c.first.as_str(), c.middle.as_str()), ("van der Berg; Jr.", "Anna,Maria", "Q. R."));
|
||||
assert_eq!((c.company.as_str(), c.department.as_str()), ("Semi;colon & Co", "R\\&D, Lab 2"));
|
||||
assert_eq!(c.nickname, "Annie, Am");
|
||||
// Fewer parts than the format has, and none at all.
|
||||
assert_eq!(card("N:Lovelace").last, "Lovelace");
|
||||
assert_eq!(card("N:;;;;\nFN:x").first, "");
|
||||
assert_eq!(card("ORG:;Only a department").department, "Only a department");
|
||||
// In 2.1 a comma is a comma.
|
||||
let old = parse("BEGIN:VCARD\nVERSION:2.1\nN:Smith, Jr.;John\nADR:;;1 Main St, Apt 4;Riverton\nEND:VCARD\n").remove(0);
|
||||
assert_eq!((old.last.as_str(), old.addresses[0].street.as_str()), ("Smith, Jr.", "1 Main St, Apt 4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_join_the_lines_above_the_city() {
|
||||
let c =
|
||||
card("ADR:PO Box 9;Floor 2;41 Harbor Road,Building C;Riverton;CA;94000;USA\nADR:;;;;;;\nADR;TYPE=HOME:;;;;;;Japan\nADR:;;Just a street");
|
||||
assert_eq!(c.addresses.len(), 3);
|
||||
assert_eq!(c.addresses[0].street, "PO Box 9, Floor 2, 41 Harbor Road, Building C");
|
||||
assert_eq!(c.addresses[0].one_line(), "PO Box 9, Floor 2, 41 Harbor Road, Building C, Riverton CA 94000, USA");
|
||||
assert_eq!((c.addresses[1].label.as_str(), c.addresses[1].country.as_str()), ("home", "Japan"));
|
||||
assert_eq!(c.addresses[2].street, "Just a street");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn birthdays_in_every_form() {
|
||||
for (text, want) in [
|
||||
("1985-04-12", Some(d(1985, 4, 12))),
|
||||
("19850412", Some(d(1985, 4, 12))),
|
||||
("1985-04-12T10:30:00Z", Some(d(1985, 4, 12))),
|
||||
("19850412T103000", Some(d(1985, 4, 12))),
|
||||
(" 1985-04-12 10:30 ", Some(d(1985, 4, 12))),
|
||||
("--0412", None),
|
||||
("1985-02-30", None),
|
||||
("spring", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(birthday(text), want, "{text}");
|
||||
}
|
||||
assert_eq!(card("FN:x\nBDAY;VALUE=date:2000-02-29").birthday, Some(d(2000, 2, 29)));
|
||||
// A later valid one counts when the first is junk.
|
||||
assert_eq!(card("FN:x\nBDAY:--0412\nBDAY:1985-04-12").birthday, Some(d(1985, 4, 12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_and_single_line_fields() {
|
||||
for (line, want) in [
|
||||
("X-FAVORITE:TRUE", true),
|
||||
("X-MAILCRAFT-FAVORITE:true", true),
|
||||
("X-FAVORITE:1", true),
|
||||
("X-FAVORITE: yes ", true),
|
||||
("X-FAVORITE:FALSE", false),
|
||||
("X-FAVORITE:", false),
|
||||
] {
|
||||
assert_eq!(card(&format!("FN:x\n{line}")).favorite, want, "{line}");
|
||||
}
|
||||
assert!(card("FN:x\nX-FAVORITE:TRUE\nX-FAVORITE:FALSE").favorite);
|
||||
// Line breaks have no place in a name, an address or a number.
|
||||
let c =
|
||||
card("FN: Ada\\nLovelace \nEMAIL: ada@example.org \nTEL:+1 555\\n0100\nURL: https://example.org \nUID: abc \nCATEGORIES: a , ,b\\nc ");
|
||||
assert_eq!(c.display_name, "Ada Lovelace");
|
||||
assert_eq!(
|
||||
(c.emails[0].value.as_str(), c.phones[0].value.as_str(), c.websites[0].as_str(), c.uid.as_str()),
|
||||
("ada@example.org", "+1 555 0100", "https://example.org", "abc")
|
||||
);
|
||||
assert_eq!(c.categories, ["a", "b c"]);
|
||||
// Empty values are not entries.
|
||||
let empty = card("FN:x\nEMAIL:\nEMAIL;TYPE=WORK: \nTEL:\nURL:\nNOTE:\nCATEGORIES:,,");
|
||||
assert!(
|
||||
empty.emails.is_empty() && empty.phones.is_empty() && empty.websites.is_empty() && empty.categories.is_empty() && empty.notes.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_text_and_unknown_encodings() {
|
||||
// "Hello, world" as an old 2.1 writer and a 3.0 writer encode it.
|
||||
assert_eq!(card("NOTE;ENCODING=BASE64:SGVsbG8sIHdvcmxk").notes, "Hello, world");
|
||||
assert_eq!(card("NOTE;ENCODING=b:SGVsbG8s\n IHdvcmxk").notes, "Hello, world");
|
||||
assert_eq!(card("NOTE;ENCODING=8BIT:as it is").notes, "as it is");
|
||||
assert_eq!(card("FN:x\nNOTE;ENCODING=b:!!!").notes, "");
|
||||
// Quoted-printable in a named Western charset, and in none.
|
||||
assert_eq!(card("FN;CHARSET=windows-1252;ENCODING=QUOTED-PRINTABLE:Zo=EB =96 caf=E9").display_name, "Zoë – café");
|
||||
assert_eq!(card("FN;QUOTED-PRINTABLE:Zo=EB").display_name, "Zoë");
|
||||
assert_eq!(card("FN;QUOTED-PRINTABLE:Zo=C3=AB").display_name, "Zoë");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn central_european_charsets() {
|
||||
// A 2.1 card from a Central European phone or mail program, in Windows-1250 and in
|
||||
// ISO-8859-2, was read as Windows-1252 or UTF-8.
|
||||
let cards = parse(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:2.1\r\n\
|
||||
N;CHARSET=windows-1250;ENCODING=QUOTED-PRINTABLE:=8Eagar;=D0ur=F0ica;;;\r\n\
|
||||
FN;CHARSET=windows-1250;ENCODING=QUOTED-PRINTABLE:=D0ur=F0ica =8Eagar\r\n\
|
||||
ORG;CHARSET=windows-1250;ENCODING=QUOTED-PRINTABLE:Gvo=9E=F0e d.o.o.\r\n\
|
||||
NOTE;CHARSET=windows-1250;ENCODING=QUOTED-PRINTABLE:We=92ve met =96 twice, in =8Aibenik =\r\n\
|
||||
and =C8akovec.\r\n\
|
||||
EMAIL;INTERNET:durdica@example.org\r\n\
|
||||
END:VCARD\r\n\
|
||||
BEGIN:VCARD\r\n\
|
||||
VERSION:2.1\r\n\
|
||||
N;CHARSET=ISO-8859-2;ENCODING=QUOTED-PRINTABLE:Kova=E8i=E6;=AEeljko;;;\r\n\
|
||||
FN;CHARSET=ISO-8859-2;ENCODING=QUOTED-PRINTABLE:=AEeljko Kova=E8i=E6\r\n\
|
||||
TITLE;CHARSET=iso-8859-2;ENCODING=QUOTED-PRINTABLE:Gvo=BE=F0e i =B9ljunak\r\n\
|
||||
EMAIL;INTERNET:zeljko@example.org\r\n\
|
||||
END:VCARD\r\n",
|
||||
);
|
||||
assert_eq!(cards.len(), 2);
|
||||
let (first, second) = (&cards[0], &cards[1]);
|
||||
assert_eq!((first.display_name.as_str(), first.first.as_str(), first.last.as_str()), ("Đurđica Žagar", "Đurđica", "Žagar"));
|
||||
assert_eq!(first.company, "Gvožđe d.o.o.");
|
||||
assert_eq!(first.notes, "We’ve met – twice, in Šibenik and Čakovec.");
|
||||
assert_eq!((second.display_name.as_str(), second.first.as_str(), second.last.as_str()), ("Željko Kovačić", "Željko", "Kovačić"));
|
||||
assert_eq!(second.title, "Gvožđe i šljunak");
|
||||
// A set no standard names is read as UTF-8, as mail is.
|
||||
assert_eq!(card("FN;CHARSET=x-unknown;ENCODING=QUOTED-PRINTABLE:Zo=C3=AB").display_name, "Zoë");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_8_bit_values_are_read_in_their_charset() {
|
||||
// Raw Windows-1250 bytes (not quoted-printable) were read as UTF-8 before the charset
|
||||
// was looked at, and đ ć č Š became U+FFFD.
|
||||
let cards = parse_bytes(
|
||||
b"BEGIN:VCARD\r\nVERSION:2.1\r\n\
|
||||
N;CHARSET=WINDOWS-1250:Gvo\xf0\xe6i\xe8;Ivana;;;\r\n\
|
||||
FN;CHARSET=WINDOWS-1250:Ivana Gvo\xf0\xe6i\xe8\r\n\
|
||||
ORG;CHARSET=WINDOWS-1250:\x8akola \xe8e\x9a\xe6e\r\n\
|
||||
NOTE;CHARSET=\"iso-8859-2\":Gvo\xbe\xf0e\r\n \xae\xb9\xe8\xe6\r\n\
|
||||
TITLE;CHARSET=windows-1250;ENCODING=QUOTED-PRINTABLE:=8Aef\r\n\
|
||||
X-SOMETHING:\xff\r\n\
|
||||
EMAIL;INTERNET:ivana@example.org\r\n\
|
||||
END:VCARD\r\n",
|
||||
);
|
||||
assert_eq!(cards.len(), 1);
|
||||
let c = &cards[0];
|
||||
assert_eq!((c.display_name.as_str(), c.last.as_str(), c.first.as_str()), ("Ivana Gvođćič", "Gvođćič", "Ivana"));
|
||||
assert_eq!(c.company, "Škola češće");
|
||||
// A folded line goes on in the charset of the property it continues (2.1 keeps the space).
|
||||
assert_eq!(c.notes, "Gvožđe Žščć");
|
||||
assert_eq!(c.title, "Šef");
|
||||
assert_eq!(c.emails[0].value, "ivana@example.org");
|
||||
// Without a charset: UTF-8 when it is UTF-8, else Windows-1252; in UTF-8 nothing changes.
|
||||
assert_eq!(parse_bytes(b"BEGIN:VCARD\nFN:Zo\xc3\xab\nEND:VCARD")[0].display_name, "Zoë");
|
||||
assert_eq!(parse_bytes(b"BEGIN:VCARD\nFN:Zo\xeb \x96 caf\xe9\nEND:VCARD")[0].display_name, "Zoë – café");
|
||||
assert_eq!(parse_bytes("\u{feff}BEGIN:VCARD\r\nFN;CHARSET=UTF-8:Đurđica\r\nEND:VCARD".as_bytes())[0].display_name, "Đurđica");
|
||||
// A file in UTF-16 with its byte order mark.
|
||||
let utf16: Vec<u8> = "\u{feff}BEGIN:VCARD\r\nFN:Željko\r\nEND:VCARD\r\n".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
assert_eq!(parse_bytes(&utf16)[0].display_name, "Željko");
|
||||
// Bytes and text give the same cards.
|
||||
let text = crate::write(&cards.iter().collect::<Vec<_>>());
|
||||
assert_eq!(parse_bytes(text.as_bytes()), parse(&text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_properties_cost_nothing() {
|
||||
let photo = "A".repeat(3 << 20);
|
||||
let c = card(&format!(
|
||||
"FN:Ada\nPHOTO;ENCODING=b;TYPE=JPEG:{photo}\nLOGO;VALUE=uri:https://example.org/logo.png\nSOUND;ENCODING=QUOTED-PRINTABLE:=41=42\nKEY;TYPE=PGP:{photo}\nX-UNKNOWN;ENCODING=b:{photo}\nTITLE:After"
|
||||
));
|
||||
assert_eq!((c.display_name.as_str(), c.title.as_str()), ("Ada", "After"));
|
||||
assert_eq!(c, Contact { display_name: "Ada".into(), title: "After".into(), ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_gives_an_empty_list() {
|
||||
for junk in [
|
||||
"",
|
||||
"\r\n\r\n",
|
||||
"hello",
|
||||
"BEGIN",
|
||||
"BEGIN:",
|
||||
"END:VCARD",
|
||||
"BEGIN:VCARD",
|
||||
"BEGIN:VCARD\nEND:VCARD",
|
||||
"BEGIN:VCARD\nVERSION:3.0\nN:;;;;\nFN:\nEND:VCARD",
|
||||
"FN:Nobody\nEMAIL:n@example.org",
|
||||
"\u{0}\u{1}\u{2}\u{fffd}PK\u{3}\u{4}",
|
||||
"::::;;;;====",
|
||||
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:x\nEND:VEVENT\nEND:VCALENDAR",
|
||||
] {
|
||||
assert!(parse(junk).is_empty(), "{junk:?}");
|
||||
}
|
||||
let binary: String = (0u32..2000).filter_map(|i| char::from_u32(i.wrapping_mul(2_654_435_761) % 0x11_0000)).collect();
|
||||
assert!(parse(&binary).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_end_loses_nothing() {
|
||||
// The text stops in the middle of the second card.
|
||||
let cut = parse("BEGIN:VCARD\nFN:Ada\nEND:VCARD\nBEGIN:VCARD\nFN:Grace\nEMAIL:grace@exam");
|
||||
assert_eq!(cut.iter().map(|c| c.display_name.as_str()).collect::<Vec<_>>(), ["Ada", "Grace"]);
|
||||
assert_eq!(cut[1].emails[0].value, "grace@exam");
|
||||
// A card that never ends, and two that do: each keeps its own lines, in the order they began.
|
||||
let run_on = parse("BEGIN:VCARD\nFN:Ada\nBEGIN:VCARD\nFN:Grace\nTITLE:Admiral\nEND:VCARD\nBEGIN:VCARD\nFN:Mei\nEND:VCARD\n");
|
||||
assert_eq!(
|
||||
run_on.iter().map(|c| (c.display_name.as_str(), c.title.as_str())).collect::<Vec<_>>(),
|
||||
[("Ada", ""), ("Grace", "Admiral"), ("Mei", "")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_cards_and_foreign_components_are_contained() {
|
||||
// A 2.1 AGENT holds a whole card; the outer card goes on after it.
|
||||
let agent = parse(
|
||||
"BEGIN:VCARD\nVERSION:2.1\nFN:Nadia Okafor\nAGENT:\nBEGIN:VCARD\nVERSION:2.1\nFN:Tomas Lindqvist\nTEL;WORK:+1 555 0100\nEND:VCARD\nTITLE:Director\nEND:VCARD\n",
|
||||
);
|
||||
assert_eq!(agent.len(), 2);
|
||||
assert_eq!((agent[0].display_name.as_str(), agent[0].title.as_str(), agent[0].phones.len()), ("Nadia Okafor", "Director", 0));
|
||||
assert_eq!((agent[1].display_name.as_str(), agent[1].title.as_str(), agent[1].phones.len()), ("Tomas Lindqvist", "", 1));
|
||||
// Something that isn't a card keeps its lines to itself, ended or not.
|
||||
let foreign = parse(
|
||||
"BEGIN:VCARD\nFN:Ada\nBEGIN:X-EXTRA\nTITLE:Inner\nBEGIN:X-DEEPER\nNOTE:Inner\nEND:X-DEEPER\nEND:X-EXTRA\nTITLE:Outer\nBEGIN:X-OPEN\nNOTE:Inner\nEND:VCARD\n",
|
||||
);
|
||||
assert_eq!(foreign, [Contact { display_name: "Ada".into(), title: "Outer".into(), ..Default::default() }]);
|
||||
// Stray ENDs, and nesting deeper than is followed: every card still comes out once.
|
||||
assert!(parse("END:VCARD\nEND:VCARD\nEND:X\n").is_empty());
|
||||
let deep: String = (0..50).map(|i| format!("BEGIN:VCARD\nFN:Card {i}\n")).collect();
|
||||
let got = parse(&deep);
|
||||
assert_eq!(got.len(), 50);
|
||||
assert_eq!(got.iter().map(|c| c.display_name.clone()).collect::<Vec<_>>(), (0..50).map(|i| format!("Card {i}")).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_and_counts_are_capped() {
|
||||
let note = "é".repeat(1 << 20);
|
||||
let c = card(&format!("FN:{}\nNOTE:{note}", "x".repeat(3 << 20)));
|
||||
assert!(c.display_name.len() <= 1 << 20 && c.display_name.len() > 1_000_000);
|
||||
assert!(c.notes.len() <= 1 << 20);
|
||||
let crowded = card(&format!(
|
||||
"FN:x\n{}{}{}{}CATEGORIES:{}\n{}",
|
||||
"EMAIL:someone@example.org\n".repeat(MAX_ITEMS + 20),
|
||||
"TEL:+1 555 0100\n".repeat(MAX_ITEMS + 20),
|
||||
"ADR:;;1 Main St\n".repeat(MAX_ITEMS + 20),
|
||||
"URL:https://example.org\n".repeat(MAX_ITEMS + 20),
|
||||
"a,".repeat(MAX_CATEGORIES + 20),
|
||||
(0..MAX_LABELS + 20).map(|i| format!("g{i}.X-ABLabel:L{i}\n")).collect::<String>(),
|
||||
));
|
||||
assert_eq!(
|
||||
(crowded.emails.len(), crowded.phones.len(), crowded.addresses.len(), crowded.websites.len(), crowded.categories.len()),
|
||||
(MAX_ITEMS, MAX_ITEMS, MAX_ITEMS, MAX_ITEMS, MAX_CATEGORIES)
|
||||
);
|
||||
let many = "BEGIN:VCARD\nFN:x\nEND:VCARD\n".repeat(MAX_CARDS + 10);
|
||||
assert_eq!(parse(&many).len(), MAX_CARDS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Whole-crate properties: what is written reads back the same, and no text makes the reader panic.
|
||||
|
||||
use crate::{Contact, Labeled, PostalAddress};
|
||||
use chrono::NaiveDate;
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::{parse, write};
|
||||
|
||||
/// What reading back gives for a written contact: the name the card was written under.
|
||||
fn as_read(c: &Contact) -> Contact {
|
||||
Contact { display_name: c.name(), ..c.clone() }
|
||||
}
|
||||
|
||||
fn round_trip(contacts: &[Contact]) -> Vec<Contact> {
|
||||
parse(&write(&contacts.iter().collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn sample_contacts() -> Vec<Contact> {
|
||||
let nadia = Contact {
|
||||
display_name: "Dr. Nadia Okafor".into(),
|
||||
first: "Nadia".into(),
|
||||
middle: "Chidi Rose".into(),
|
||||
last: "Okafor".into(),
|
||||
nickname: "Nadi, \"the planner\"".into(),
|
||||
company: "Example Research, Inc.".into(),
|
||||
title: "Lead Scientist; Field".into(),
|
||||
department: "Field Lab \\ Unit 7".into(),
|
||||
emails: vec![
|
||||
Labeled::new("work", "nadia.okafor@example.org"),
|
||||
Labeled::new("", "nadia@example.org"),
|
||||
Labeled::new("Family", "nadia@family.example.org"),
|
||||
Labeled::new("mobile", "nadia@phone.example.org"),
|
||||
],
|
||||
phones: vec![
|
||||
Labeled::new("mobile", "+1 (555) 010-0142"),
|
||||
Labeled::new("work", "+1 555 010 0143"),
|
||||
Labeled::new("home", "+1 555 010 0144"),
|
||||
Labeled::new("fax", "+1 555 010 0145"),
|
||||
Labeled::new("pager", "+1 555 010 0146"),
|
||||
Labeled::new("other", "+1 555 010 0147"),
|
||||
Labeled::new("", "+1 555 010 0148"),
|
||||
Labeled::new("Car; old, blue", "+1 555 010 0149"),
|
||||
],
|
||||
addresses: vec![
|
||||
PostalAddress {
|
||||
label: "work".into(),
|
||||
street: "Suite 300, 41 Harbor Road".into(),
|
||||
city: "Riverton".into(),
|
||||
region: "CA".into(),
|
||||
postal_code: "94000".into(),
|
||||
country: "USA".into(),
|
||||
},
|
||||
PostalAddress { label: "home".into(), street: "7; Rue de l'Étang".into(), city: "Saint-Rémy".into(), ..Default::default() },
|
||||
PostalAddress { label: "Cabin".into(), country: "Norway".into(), ..Default::default() },
|
||||
PostalAddress { postal_code: "12345".into(), ..Default::default() },
|
||||
],
|
||||
websites: vec!["https://example.org/~nadia?a=1,2;b=3".into(), "example.org".into()],
|
||||
birthday: NaiveDate::from_ymd_opt(1985, 4, 12),
|
||||
notes: "Met at the 2024 symposium.\nPrefers e-mail, not calls; never on Fridays.\n\n\tC:\\notes\\nadia.txt\n".into(),
|
||||
categories: vec!["Research".into(), "Field Team, West".into()],
|
||||
favorite: true,
|
||||
uid: "7f1c2f6e-0001-4a5b-9c3d-000000000042".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let unnamed =
|
||||
Contact { first: "Jürgen".into(), last: "Müller".into(), phones: vec![Labeled::new("mobile", "+49 170 5550101")], ..Default::default() };
|
||||
let company =
|
||||
Contact { company: "東京デザイン株式会社".into(), websites: vec!["https://design.example.org".into()], ..Default::default() };
|
||||
let address_only = Contact { emails: vec![Labeled::new("other", "someone@example.org")], ..Default::default() };
|
||||
let bare = Contact { favorite: true, ..Default::default() };
|
||||
let emoji = Contact {
|
||||
display_name: "Zoë 🎉 “Zed” Ødegård".into(),
|
||||
notes: " leading and trailing spaces stay ".into(),
|
||||
birthday: NaiveDate::from_ymd_opt(2000, 2, 29),
|
||||
..Default::default()
|
||||
};
|
||||
vec![nadia, unnamed, company, address_only, bare, emoji]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_contacts_round_trip() {
|
||||
let contacts = sample_contacts();
|
||||
let back = round_trip(&contacts);
|
||||
assert_eq!(back, contacts.iter().map(as_read).collect::<Vec<_>>());
|
||||
assert_eq!(back[1].display_name, "Jürgen Müller");
|
||||
assert_eq!(back[3].display_name, "someone@example.org");
|
||||
assert_eq!(back[4].display_name, "");
|
||||
// What was read writes the same text again.
|
||||
let text = write(&back.iter().collect::<Vec<_>>());
|
||||
assert_eq!(parse(&text), back);
|
||||
assert_eq!(write(&parse(&text).iter().collect::<Vec<_>>()), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_contact_does_not_come_back() {
|
||||
assert!(round_trip(&[Contact::default(), Contact::default()]).is_empty());
|
||||
// Entries with nothing in them are left out.
|
||||
let hollow = Contact {
|
||||
display_name: "Ada".into(),
|
||||
emails: vec![Labeled::new("work", ""), Labeled::new("home", "ada@example.org")],
|
||||
phones: vec![Labeled::new("mobile", " ")],
|
||||
addresses: vec![PostalAddress { label: "home".into(), ..Default::default() }],
|
||||
websites: vec![String::new()],
|
||||
categories: vec![" ".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let back = round_trip(std::slice::from_ref(&hollow));
|
||||
assert_eq!(back, [Contact { display_name: "Ada".into(), emails: vec![Labeled::new("home", "ada@example.org")], ..Default::default() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_from_other_programs_survive_a_round_trip() {
|
||||
// A 2.1 card read, written as 3.0 and read again says the same.
|
||||
let old = "BEGIN:VCARD\r\nVERSION:2.1\r\nN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen\r\nTEL;WORK;VOICE:+49 30 5550100\r\nTEL;CELL:+49 170 5550101\r\n\
|
||||
EMAIL;PREF;INTERNET:jm@example.org\r\nADR;HOME:;;Gartenweg 12, Hinterhaus;Berlin;;10115;Deutschland\r\nNOTE;ENCODING=QUOTED-PRINTABLE:Zeile eins=0D=0AZeile =\r\nzwei\r\nEND:VCARD\r\n";
|
||||
let first = parse(old);
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].notes, "Zeile eins\nZeile zwei");
|
||||
assert_eq!(first[0].addresses[0].street, "Gartenweg 12, Hinterhaus");
|
||||
assert_eq!(round_trip(&first), first.iter().map(as_read).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// One line of text as a contact field holds it: printable, trimmed.
|
||||
fn line() -> impl Strategy<Value = String> {
|
||||
prop_oneof!["(\\PC|[;,\\\\:\"^=.]){0,24}", "[ -~]{0,60}", Just(String::new())].prop_map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
/// A line with something in it: an empty one gets a character.
|
||||
fn filled_line() -> impl Strategy<Value = String> {
|
||||
(line(), "[!-~]").prop_map(|(line, fallback)| if line.is_empty() { fallback } else { line })
|
||||
}
|
||||
|
||||
fn label(known: &'static [&'static str]) -> impl Strategy<Value = String> {
|
||||
prop_oneof![proptest::sample::select(known).prop_map(str::to_string), Just(String::new()), line()]
|
||||
}
|
||||
|
||||
fn date() -> impl Strategy<Value = NaiveDate> {
|
||||
(1i32..=9999, 1u32..=12, 1u32..=28).prop_map(|(y, m, day)| NaiveDate::from_ymd_opt(y, m, day).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn address() -> impl Strategy<Value = PostalAddress> {
|
||||
// One part, any of them, always says something.
|
||||
(label(&["work", "home", "other"]), [line(), line(), line(), line(), line()], 0usize..5, filled_line()).prop_map(
|
||||
|(label, mut parts, filled, text)| {
|
||||
parts[filled] = text;
|
||||
let [street, city, region, postal_code, country] = parts;
|
||||
PostalAddress { label, street, city, region, postal_code, country }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn contact() -> impl Strategy<Value = Contact> {
|
||||
let names = (line(), line(), line(), line(), line(), line(), line(), line());
|
||||
let emails = proptest::collection::vec((label(&["work", "home", "other"]), filled_line()), 0..4);
|
||||
let phones = proptest::collection::vec((label(&["mobile", "work", "home", "fax", "pager", "other"]), filled_line()), 0..4);
|
||||
let lists = (
|
||||
emails,
|
||||
phones,
|
||||
proptest::collection::vec(address(), 0..3),
|
||||
proptest::collection::vec(filled_line(), 0..3),
|
||||
proptest::collection::vec(filled_line(), 0..3),
|
||||
);
|
||||
let rest = (proptest::option::of(date()), "(\\PC|[\n\t;,\\\\:]){0,120}", any::<bool>(), line());
|
||||
(names, lists, rest).prop_map(|(names, lists, rest)| {
|
||||
let (display_name, first, middle, last, nickname, company, title, department) = names;
|
||||
let (emails, phones, addresses, websites, categories) = lists;
|
||||
let (birthday, notes, favorite, uid) = rest;
|
||||
let labeled = |list: Vec<(String, String)>| list.into_iter().map(|(label, value)| Labeled { label, value }).collect();
|
||||
Contact {
|
||||
display_name,
|
||||
first,
|
||||
middle,
|
||||
last,
|
||||
nickname,
|
||||
company,
|
||||
title,
|
||||
department,
|
||||
emails: labeled(emails),
|
||||
phones: labeled(phones),
|
||||
addresses,
|
||||
websites,
|
||||
birthday,
|
||||
notes,
|
||||
categories,
|
||||
favorite,
|
||||
uid,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Text that looks like a card without being one.
|
||||
fn soup() -> impl Strategy<Value = String> {
|
||||
let piece = prop_oneof![
|
||||
proptest::sample::select(vec![
|
||||
"BEGIN:VCARD",
|
||||
"END:VCARD",
|
||||
"BEGIN:",
|
||||
"END:",
|
||||
"BEGIN:X",
|
||||
"END:X",
|
||||
"VERSION:2.1",
|
||||
"VERSION:3.0",
|
||||
"VERSION:4.0",
|
||||
"FN",
|
||||
"N",
|
||||
"NICKNAME",
|
||||
"ORG",
|
||||
"TITLE",
|
||||
"EMAIL",
|
||||
"TEL",
|
||||
"ADR",
|
||||
"URL",
|
||||
"BDAY",
|
||||
"NOTE",
|
||||
"CATEGORIES",
|
||||
"UID",
|
||||
"PHOTO",
|
||||
"X-ABLabel",
|
||||
"X-FAVORITE",
|
||||
"item1.",
|
||||
"item2.",
|
||||
".",
|
||||
":",
|
||||
";",
|
||||
",",
|
||||
"=",
|
||||
"\"",
|
||||
"\\",
|
||||
"\r\n",
|
||||
"\n",
|
||||
"\r\n ",
|
||||
"\n\t",
|
||||
" ",
|
||||
"=\r\n",
|
||||
"=\n",
|
||||
"=C3=A9",
|
||||
"=C3",
|
||||
"=ZZ",
|
||||
"\\n",
|
||||
"\\;",
|
||||
"\\,",
|
||||
";TYPE=WORK,VOICE",
|
||||
";TYPE=\"cell,pref\"",
|
||||
";HOME",
|
||||
";CELL",
|
||||
";ENCODING=QUOTED-PRINTABLE",
|
||||
";QUOTED-PRINTABLE",
|
||||
";ENCODING=b",
|
||||
";BASE64",
|
||||
";CHARSET=ISO-8859-1",
|
||||
";CHARSET=UTF-8",
|
||||
";CHARSET=x",
|
||||
"mailto:",
|
||||
"tel:",
|
||||
"a@example.org",
|
||||
"+1 555 0100",
|
||||
"1985-04-12",
|
||||
"19850412T101010Z",
|
||||
"--0412",
|
||||
"0000-00-00",
|
||||
"TRUE",
|
||||
"SGVsbG8=",
|
||||
"_$!<Work>!$_",
|
||||
"_$!<",
|
||||
])
|
||||
.prop_map(str::to_string),
|
||||
"\\PC{0,6}",
|
||||
any::<String>(),
|
||||
];
|
||||
proptest::collection::vec(piece, 0..60).prop_map(|pieces| pieces.concat())
|
||||
}
|
||||
|
||||
fn valid_text() -> String {
|
||||
write(&sample_contacts().iter().collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Whatever the reader gives must also be writable and readable again.
|
||||
fn survives(text: &str) -> Vec<Contact> {
|
||||
let contacts = parse(text);
|
||||
let again = write(&contacts.iter().collect::<Vec<_>>());
|
||||
let _ = parse(&again);
|
||||
contacts
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn any_contact_round_trips(contacts in proptest::collection::vec(contact(), 0..4)) {
|
||||
let back = round_trip(&contacts);
|
||||
// A contact with nothing in it isn't written back as one.
|
||||
let want: Vec<Contact> = contacts.iter().map(as_read).filter(|c| *c != Contact::default()).collect();
|
||||
prop_assert_eq!(back, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_notes_and_names_round_trip(notes in "(\\PC|[\n\t]){0,400}", name in "\\PC{0,200}", street in "\\PC{0,100}") {
|
||||
let name = name.trim().to_string();
|
||||
let street = street.trim().to_string();
|
||||
let c = Contact { display_name: name.clone(), last: name.clone(), company: name.clone(), notes, addresses: vec![PostalAddress { street, city: "Riverton".into(), ..Default::default() }], uid: "u".into(), ..Default::default() };
|
||||
prop_assert_eq!(round_trip(std::slice::from_ref(&c)), vec![as_read(&c)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_control_characters_reads_back_clean_and_stable(name in any::<String>(), notes in any::<String>(), label in any::<String>(), value in any::<String>(), category in any::<String>()) {
|
||||
let c = Contact {
|
||||
display_name: name.clone(),
|
||||
first: name,
|
||||
notes,
|
||||
emails: vec![Labeled::new(label.clone(), value.clone())],
|
||||
phones: vec![Labeled::new(label.clone(), value.clone())],
|
||||
addresses: vec![PostalAddress { label, street: value.clone(), ..Default::default() }],
|
||||
websites: vec![value],
|
||||
categories: vec![category],
|
||||
uid: "u".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let once = round_trip(std::slice::from_ref(&c));
|
||||
prop_assert_eq!(once.len(), 1);
|
||||
// The first pass cleans (control characters, stray spaces); after that nothing changes but a
|
||||
// display name that was empty.
|
||||
prop_assert_eq!(round_trip(&once), once.iter().map(as_read).collect::<Vec<_>>());
|
||||
prop_assert!(!once[0].display_name.contains(['\r', '\n']) && !once[0].notes.contains('\r'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_text_never_panics(text in any::<String>()) {
|
||||
survives(&text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let contacts = crate::parse_bytes(&bytes);
|
||||
let _ = parse(&write(&contacts.iter().collect::<Vec<_>>()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_shaped_bytes_never_panic(text in soup(), high in proptest::collection::vec((any::<prop::sample::Index>(), 0x80u8..=0xFF), 0..12), charset in proptest::sample::select(vec!["", "windows-1250", "UTF-8", "utf-16", "iso-2022-jp", "shift_jis", "x-unknown"])) {
|
||||
// Card-shaped text with a charset named and raw 8-bit bytes dropped in.
|
||||
let mut bytes = text.replace("FN:", &format!("FN;CHARSET={charset}:")).into_bytes();
|
||||
for (at, byte) in high {
|
||||
if !bytes.is_empty() {
|
||||
let i = at.index(bytes.len());
|
||||
bytes.insert(i, byte);
|
||||
}
|
||||
}
|
||||
let _ = crate::parse_bytes(&bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_bytes_read_as_the_text_does(text in soup()) {
|
||||
// (A value in UTF-8 that says it is Latin-1 is read as Latin-1 from bytes.)
|
||||
let text = text.replace("ISO-8859-1", "UTF-8");
|
||||
prop_assert_eq!(crate::parse_bytes(text.as_bytes()), parse(&text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_shaped_junk_never_panics(text in soup()) {
|
||||
survives(&text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_card_never_panics(cuts in proptest::collection::vec((any::<prop::sample::Index>(), 0usize..40, 0u8..5, "\\PC{0,8}|[\r\n:;,=\"\\\\ .]{1,4}"), 1..6)) {
|
||||
let mut chars: Vec<char> = valid_text().chars().collect();
|
||||
for (index, length, kind, junk) in cuts {
|
||||
let from = index.index(chars.len().max(1)).min(chars.len());
|
||||
let to = from.saturating_add(length).min(chars.len());
|
||||
match kind {
|
||||
// Cut a piece out, overwrite it, insert junk, double a piece, or stop the text there.
|
||||
0 => drop(chars.drain(from..to)),
|
||||
1 => drop(chars.splice(from..to, junk.chars())),
|
||||
2 => drop(chars.splice(from..from, junk.chars())),
|
||||
3 => {
|
||||
let piece: Vec<char> = chars[from..to].to_vec();
|
||||
drop(chars.splice(from..from, piece));
|
||||
}
|
||||
_ => chars.truncate(from),
|
||||
}
|
||||
}
|
||||
let damaged: String = chars.into_iter().collect();
|
||||
prop_assert!(survives(&damaged).len() <= 64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_cut_anywhere_never_panics() {
|
||||
let text = valid_text();
|
||||
for (cut, _) in text.char_indices().step_by(5) {
|
||||
survives(&text[..cut]);
|
||||
survives(&text[cut..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Text values: cleaning, escaping, splitting, and the 2.1 encodings (quoted-printable, base64;
|
||||
//! a `CHARSET` is read by `craft_mime::charset`, as mail is).
|
||||
|
||||
/// A one-line field: line breaks and other control characters become spaces, and the ends are
|
||||
/// trimmed.
|
||||
pub(crate) fn single(s: &str) -> String {
|
||||
let spaced: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
spaced.trim().to_string()
|
||||
}
|
||||
|
||||
/// Text of several lines: `\r\n` and `\r` become `\n`; other control characters except tab are
|
||||
/// dropped.
|
||||
pub(crate) fn multi(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut after_cr = false;
|
||||
for c in s.chars() {
|
||||
let cr = c == '\r';
|
||||
match c {
|
||||
'\n' if after_cr => {}
|
||||
'\r' | '\n' => out.push('\n'),
|
||||
'\t' => out.push(c),
|
||||
c if c.is_control() => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
after_cr = cr;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A value ready to write: `\`, `;`, `,` and line breaks escaped.
|
||||
pub(crate) fn escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len().saturating_add(8));
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
';' => out.push_str("\\;"),
|
||||
',' => out.push_str("\\,"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Undo [`escape`]: `\n` and `\N` are line breaks, `\\`, `\;`, `\,` (and the `\:` some writers
|
||||
/// add) their second character; any other backslash is kept.
|
||||
pub(crate) fn unescape(s: &str) -> String {
|
||||
if !s.contains('\\') {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('n' | 'N') => out.push('\n'),
|
||||
Some(x @ ('\\' | ';' | ',' | ':')) => out.push(x),
|
||||
Some(x) => {
|
||||
out.push('\\');
|
||||
out.push(x);
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first `max` pieces of `s`, split on its unescaped `separator`s; the pieces are still
|
||||
/// escaped.
|
||||
pub(crate) fn split(s: &str, separator: char, max: usize) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let (mut start, mut escaped) = (0usize, false);
|
||||
for (i, c) in s.char_indices() {
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == separator {
|
||||
out.push(s.get(start..i).unwrap_or(""));
|
||||
start = i.saturating_add(c.len_utf8());
|
||||
}
|
||||
}
|
||||
if out.len() < max {
|
||||
out.push(s.get(start..).unwrap_or(""));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One component of a structured value (a name part, a street) as one line of text. vCard 3.0 and
|
||||
/// 4.0 let a component list several values with commas, which are joined with `joiner`; in 2.1
|
||||
/// (`v21`) a comma is just a comma.
|
||||
pub(crate) fn component(piece: &str, joiner: &str, v21: bool) -> String {
|
||||
let values = if v21 { vec![piece] } else { split(piece, ',', 32) };
|
||||
match values.as_slice() {
|
||||
[one] => single(&unescape(one)),
|
||||
many => many.iter().map(|v| single(&unescape(v))).filter(|v| !v.is_empty()).collect::<Vec<_>>().join(joiner),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of a quoted-printable value (`=C3=A9`); soft line breaks are already gone, and a `=`
|
||||
/// that isn't followed by two hex digits is kept.
|
||||
pub(crate) fn quoted_printable(s: &str) -> Vec<u8> {
|
||||
let bytes = s.as_bytes();
|
||||
let hex = |at: usize| bytes.get(at).and_then(|b| char::from(*b).to_digit(16)).and_then(|d| u8::try_from(d).ok());
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0usize;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'='
|
||||
&& let (Some(high), Some(low)) = (hex(i.saturating_add(1)), hex(i.saturating_add(2)))
|
||||
{
|
||||
out.push(high.saturating_mul(16).saturating_add(low));
|
||||
i = i.saturating_add(3);
|
||||
} else {
|
||||
out.push(b);
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of a base64 value, white space and padding or not; empty for junk.
|
||||
pub(crate) fn base64_bytes(s: &str) -> Vec<u8> {
|
||||
let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
craft_mime::base64::STANDARD_NO_PAD.decode(compact.trim_end_matches('=')).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether `c` is one of Unicode's format characters (general category Cf, Unicode 17.0):
|
||||
/// bidirectional marks, zero-width marks and joiners, the byte-order mark, the soft hyphen, tags
|
||||
/// and the rest. They show nothing and can turn the text around them.
|
||||
pub(crate) fn is_format(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{00ad}'
|
||||
| '\u{0600}'..='\u{0605}'
|
||||
| '\u{061c}'
|
||||
| '\u{06dd}'
|
||||
| '\u{070f}'
|
||||
| '\u{0890}'..='\u{0891}'
|
||||
| '\u{08e2}'
|
||||
| '\u{180e}'
|
||||
| '\u{200b}'..='\u{200f}'
|
||||
| '\u{202a}'..='\u{202e}'
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{206f}'
|
||||
| '\u{feff}'
|
||||
| '\u{fff9}'..='\u{fffb}'
|
||||
| '\u{110bd}'
|
||||
| '\u{110cd}'
|
||||
| '\u{13430}'..='\u{1343f}'
|
||||
| '\u{1bca0}'..='\u{1bca3}'
|
||||
| '\u{1d173}'..='\u{1d17a}'
|
||||
| '\u{e0001}'
|
||||
| '\u{e0020}'..='\u{e007f}'
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cleans_one_line_and_many_line_text() {
|
||||
assert_eq!(single(" Ada\r\nLovelace\t\u{0}x "), "Ada Lovelace x");
|
||||
assert_eq!(single("\n\u{85}\u{7f} "), "");
|
||||
assert_eq!(multi("a\r\nb\rc\n\nd\u{0}\u{7f}\te "), "a\nb\nc\n\nd\te ");
|
||||
assert_eq!(multi(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_and_unescapes() {
|
||||
assert_eq!(escape("Research, Inc.; Lab \\ 7\nSecond\r\n"), "Research\\, Inc.\\; Lab \\\\ 7\\nSecond\\n");
|
||||
assert_eq!(unescape("Research\\, Inc.\\; Lab \\\\ 7\\nSecond\\NThird"), "Research, Inc.; Lab \\ 7\nSecond\nThird");
|
||||
assert_eq!(unescape("http\\://example.org C:\\Users\\x tail\\"), "http://example.org C:\\Users\\x tail\\");
|
||||
assert_eq!(unescape("plain"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_on_unescaped_separators() {
|
||||
assert_eq!(split("Okafor;Nadia;;Dr.;", ';', 9), ["Okafor", "Nadia", "", "Dr.", ""]);
|
||||
assert_eq!(split("a\\;b;c\\\\;d", ';', 9), ["a\\;b", "c\\\\", "d"]);
|
||||
assert_eq!(split("a,b,c", ',', 2), ["a", "b"]);
|
||||
assert_eq!(split("", ';', 9), [""]);
|
||||
assert!(split("a;b", ';', 0).is_empty());
|
||||
assert_eq!(split("é;東;🎉", ';', 9), ["é", "東", "🎉"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_join_their_lists() {
|
||||
assert_eq!(component("Quinlan,Paul", " ", false), "Quinlan Paul");
|
||||
assert_eq!(component("Quinlan\\,Paul", " ", false), "Quinlan,Paul");
|
||||
assert_eq!(component(" 41 Harbor Road , Suite 300,, ", ", ", false), "41 Harbor Road, Suite 300");
|
||||
assert_eq!(component("Smith, Jr.", " ", true), "Smith, Jr.");
|
||||
assert_eq!(component(" one\\nline ", " ", false), "one line");
|
||||
assert_eq!(component("", ", ", false), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_quoted_printable() {
|
||||
assert_eq!(quoted_printable("J=C3=BCrgen M=c3=bcller"), "Jürgen Müller".as_bytes());
|
||||
assert_eq!(quoted_printable("a=3Db=0D=0Ac"), b"a=b\r\nc");
|
||||
// What isn't an escape stays.
|
||||
assert_eq!(quoted_printable("1+1=2 =ZZ =4"), b"1+1=2 =ZZ =4");
|
||||
assert_eq!(quoted_printable("="), b"=");
|
||||
assert_eq!(quoted_printable("é=41"), "éA".as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_base64() {
|
||||
assert_eq!(base64_bytes("SGVsbG8sIHdvcmxk"), b"Hello, world");
|
||||
assert_eq!(base64_bytes(" SGVs\r\n bG8=\n"), b"Hello");
|
||||
assert_eq!(base64_bytes("SGVsbG8"), b"Hello");
|
||||
assert!(base64_bytes("not base64!").is_empty());
|
||||
assert!(base64_bytes("").is_empty());
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn clean_text_survives_escaping(s in "(\\PC|[\n\t;,\\\\:]){0,80}") {
|
||||
proptest::prop_assert_eq!(unescape(&escape(&s)), s.clone());
|
||||
// And as one piece of a structured value or a list.
|
||||
proptest::prop_assert_eq!(split(&format!("{};x", escape(&s)), ';', 9).first().map(|p| unescape(p)), Some(s.clone()));
|
||||
proptest::prop_assert_eq!(split(&format!("{},x", escape(&s)), ',', 9).first().map(|p| unescape(p)), Some(s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_helpers_never_panic(s in proptest::prelude::any::<String>(), max in 0usize..5) {
|
||||
let _ = (single(&s), multi(&s), unescape(&s), split(&s, ';', max), component(&s, ", ", false), quoted_printable(&s), base64_bytes(&s));
|
||||
proptest::prop_assert!(!escape(&multi(&s)).contains(['\r', '\n']));
|
||||
proptest::prop_assert!(!single(&s).contains(['\r', '\n']));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Writing contacts as vCard 3.0.
|
||||
|
||||
use crate::Contact;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
|
||||
use crate::lines::fold;
|
||||
use crate::text::{escape, multi, single};
|
||||
|
||||
/// Write vCard 3.0 cards, CRLF line endings, lines folded at 75 octets on char boundaries.
|
||||
///
|
||||
/// `FN` is the contact's [`name`](Contact::name). Values are escaped. Every field but the notes is
|
||||
/// one line: its line breaks and other control characters become spaces. A label the format has a
|
||||
/// `TYPE` for (`work`, `home`, `other`; for phones also `mobile`, `fax`, `pager`) is written as
|
||||
/// that type; any other label is written as the `X-ABLabel` of a group (`item1.TEL` +
|
||||
/// `item1.X-ABLabel:Car`), which is how address books exchange custom labels. E-mail addresses,
|
||||
/// phone numbers, postal addresses and web sites with nothing in them are left out. Keys are
|
||||
/// written as `KEY;TYPE=X509;ENCODING=b:` and `KEY;TYPE=PGP;ENCODING=b:`; one that would not read
|
||||
/// back as itself is left out, and so is the address of a key, which vCard 3.0 has no form for.
|
||||
pub fn write(contacts: &[&Contact]) -> String {
|
||||
let mut out = String::new();
|
||||
for contact in contacts {
|
||||
card(&mut out, contact);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A one-line field, escaped.
|
||||
fn field(s: &str) -> String {
|
||||
escape(&single(s))
|
||||
}
|
||||
|
||||
/// `NAME:value` when there is a value.
|
||||
fn optional(out: &mut String, name: &str, value: &str) {
|
||||
let value = field(value);
|
||||
if !value.is_empty() {
|
||||
fold(out, &format!("{name}:{value}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `TYPE` of an e-mail or postal address label, when the format has one for it.
|
||||
fn place_type(label: &str) -> Option<&'static str> {
|
||||
match label {
|
||||
"work" => Some("WORK"),
|
||||
"home" => Some("HOME"),
|
||||
"other" => Some("OTHER"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn phone_type(label: &str) -> Option<&'static str> {
|
||||
match label {
|
||||
"mobile" => Some("CELL"),
|
||||
"fax" => Some("FAX"),
|
||||
"pager" => Some("PAGER"),
|
||||
_ => place_type(label),
|
||||
}
|
||||
}
|
||||
|
||||
/// One labelled property. `kind` is the type the property always has (`INTERNET`), `known` the
|
||||
/// type its label stands for; a label without one goes into an `X-ABLabel` of the same group.
|
||||
fn labeled(out: &mut String, groups: &mut u32, name: &str, kind: Option<&str>, known: Option<&str>, label: &str, value: &str) {
|
||||
let types: Vec<&str> = kind.into_iter().chain(known).collect();
|
||||
let params = if types.is_empty() { String::new() } else { format!(";TYPE={}", types.join(",")) };
|
||||
let custom = if known.is_some() { String::new() } else { field(label) };
|
||||
if custom.is_empty() {
|
||||
fold(out, &format!("{name}{params}:{value}"));
|
||||
} else {
|
||||
*groups = groups.saturating_add(1);
|
||||
fold(out, &format!("item{groups}.{name}{params}:{value}"));
|
||||
fold(out, &format!("item{groups}.X-ABLabel:{custom}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn card(out: &mut String, c: &Contact) {
|
||||
fold(out, "BEGIN:VCARD");
|
||||
fold(out, "VERSION:3.0");
|
||||
fold(out, &format!("N:{};{};{};;", field(&c.last), field(&c.first), field(&c.middle)));
|
||||
fold(out, &format!("FN:{}", field(&c.name())));
|
||||
optional(out, "NICKNAME", &c.nickname);
|
||||
let (company, department) = (field(&c.company), field(&c.department));
|
||||
if !department.is_empty() {
|
||||
fold(out, &format!("ORG:{company};{department}"));
|
||||
} else if !company.is_empty() {
|
||||
fold(out, &format!("ORG:{company}"));
|
||||
}
|
||||
optional(out, "TITLE", &c.title);
|
||||
let mut groups = 0u32;
|
||||
for email in &c.emails {
|
||||
let value = field(&email.value);
|
||||
if !value.is_empty() {
|
||||
labeled(out, &mut groups, "EMAIL", Some("INTERNET"), place_type(&email.label), &email.label, &value);
|
||||
}
|
||||
}
|
||||
for phone in &c.phones {
|
||||
let value = field(&phone.value);
|
||||
if !value.is_empty() {
|
||||
labeled(out, &mut groups, "TEL", None, phone_type(&phone.label), &phone.label, &value);
|
||||
}
|
||||
}
|
||||
for address in c.addresses.iter().filter(|a| !a.is_empty()) {
|
||||
// Post office box and extended address stay empty: the model keeps them in the street.
|
||||
let value = format!(
|
||||
";;{};{};{};{};{}",
|
||||
field(&address.street),
|
||||
field(&address.city),
|
||||
field(&address.region),
|
||||
field(&address.postal_code),
|
||||
field(&address.country)
|
||||
);
|
||||
labeled(out, &mut groups, "ADR", None, place_type(&address.label), &address.label, &value);
|
||||
}
|
||||
for site in &c.websites {
|
||||
optional(out, "URL", site);
|
||||
}
|
||||
if let Some(day) = c.birthday {
|
||||
// A date that exists, whatever the fields say.
|
||||
// A year a card can say (1 to 9999), whatever the date's is.
|
||||
let year = day.year().clamp(1, 9999);
|
||||
let day = day.with_year(year).or_else(|| NaiveDate::from_ymd_opt(year, day.month(), 28)).unwrap_or_default();
|
||||
fold(out, &format!("BDAY:{}", day.format("%Y-%m-%d")));
|
||||
}
|
||||
let notes = escape(&multi(&c.notes));
|
||||
if !notes.is_empty() {
|
||||
fold(out, &format!("NOTE:{notes}"));
|
||||
}
|
||||
let categories: Vec<String> = c.categories.iter().map(|name| field(name)).filter(|name| !name.is_empty()).collect();
|
||||
if !categories.is_empty() {
|
||||
fold(out, &format!("CATEGORIES:{}", categories.join(",")));
|
||||
}
|
||||
for key in c.keys.iter().filter_map(crate::key::line).take(Contact::MAX_KEYS) {
|
||||
fold(out, &key);
|
||||
}
|
||||
optional(out, "UID", &c.uid);
|
||||
if c.favorite {
|
||||
fold(out, "X-MAILCRAFT-FAVORITE:TRUE");
|
||||
}
|
||||
fold(out, "END:VCARD");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Labeled, PostalAddress};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn lines(c: &Contact) -> Vec<String> {
|
||||
let out = write(&[c]);
|
||||
assert!(out.ends_with("END:VCARD\r\n"));
|
||||
let mut cursor = crate::lines::Lines::new(&out);
|
||||
std::iter::from_fn(|| cursor.next_line(false)).collect()
|
||||
}
|
||||
|
||||
fn nadia() -> Contact {
|
||||
Contact {
|
||||
display_name: "Dr. Nadia Okafor".into(),
|
||||
first: "Nadia".into(),
|
||||
middle: "Chidi".into(),
|
||||
last: "Okafor".into(),
|
||||
nickname: "Nadi".into(),
|
||||
company: "Example Research, Inc.".into(),
|
||||
title: "Lead Scientist; Field".into(),
|
||||
department: "Field Lab".into(),
|
||||
emails: vec![
|
||||
Labeled::new("work", "nadia.okafor@example.org"),
|
||||
Labeled::new("", "nadia@example.org"),
|
||||
Labeled::new("Family", "nadia@family.example.org"),
|
||||
Labeled::new("home", " "),
|
||||
],
|
||||
phones: vec![
|
||||
Labeled::new("mobile", "+1 (555) 010-0142"),
|
||||
Labeled::new("fax", "+1 555 010 0144"),
|
||||
Labeled::new("Car, old", "+1 555 010 0145"),
|
||||
Labeled::new("", "+1 555 010 0146"),
|
||||
],
|
||||
addresses: vec![
|
||||
PostalAddress {
|
||||
label: "work".into(),
|
||||
street: "Suite 300, 41 Harbor Road".into(),
|
||||
city: "Riverton".into(),
|
||||
region: "CA".into(),
|
||||
postal_code: "94000".into(),
|
||||
country: "USA".into(),
|
||||
},
|
||||
PostalAddress { label: "home".into(), ..Default::default() },
|
||||
PostalAddress { label: "Cabin".into(), city: "Pinewood".into(), ..Default::default() },
|
||||
],
|
||||
websites: vec!["https://example.org/~nadia".into(), "".into()],
|
||||
birthday: NaiveDate::from_ymd_opt(1985, 4, 12),
|
||||
notes: "Met at the 2024 symposium.\r\nPrefers e-mail, not calls.".into(),
|
||||
categories: vec!["Research".into(), "Field Team, West".into(), " ".into()],
|
||||
favorite: true,
|
||||
uid: "7f1c2f6e-0001-4a5b-9c3d-000000000042".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_a_whole_card() {
|
||||
assert_eq!(
|
||||
lines(&nadia()),
|
||||
[
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
"N:Okafor;Nadia;Chidi;;",
|
||||
"FN:Dr. Nadia Okafor",
|
||||
"NICKNAME:Nadi",
|
||||
"ORG:Example Research\\, Inc.;Field Lab",
|
||||
"TITLE:Lead Scientist\\; Field",
|
||||
"EMAIL;TYPE=INTERNET,WORK:nadia.okafor@example.org",
|
||||
"EMAIL;TYPE=INTERNET:nadia@example.org",
|
||||
"item1.EMAIL;TYPE=INTERNET:nadia@family.example.org",
|
||||
"item1.X-ABLabel:Family",
|
||||
"TEL;TYPE=CELL:+1 (555) 010-0142",
|
||||
"TEL;TYPE=FAX:+1 555 010 0144",
|
||||
"item2.TEL:+1 555 010 0145",
|
||||
"item2.X-ABLabel:Car\\, old",
|
||||
"TEL:+1 555 010 0146",
|
||||
"ADR;TYPE=WORK:;;Suite 300\\, 41 Harbor Road;Riverton;CA;94000;USA",
|
||||
"item3.ADR:;;;Pinewood;;;",
|
||||
"item3.X-ABLabel:Cabin",
|
||||
"URL:https://example.org/~nadia",
|
||||
"BDAY:1985-04-12",
|
||||
"NOTE:Met at the 2024 symposium.\\nPrefers e-mail\\, not calls.",
|
||||
"CATEGORIES:Research,Field Team\\, West",
|
||||
"UID:7f1c2f6e-0001-4a5b-9c3d-000000000042",
|
||||
"X-MAILCRAFT-FAVORITE:TRUE",
|
||||
"END:VCARD",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_contact_is_still_a_card() {
|
||||
assert_eq!(write(&[&Contact::default()]), "BEGIN:VCARD\r\nVERSION:3.0\r\nN:;;;;\r\nFN:\r\nEND:VCARD\r\n");
|
||||
assert_eq!(write(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formatted_name_falls_back_as_the_model_does() {
|
||||
let named = Contact { first: "Ada".into(), last: "Lovelace".into(), ..Default::default() };
|
||||
assert!(lines(&named).contains(&"FN:Ada Lovelace".to_string()));
|
||||
let company = Contact { company: "Analytical Engines Ltd".into(), ..Default::default() };
|
||||
assert_eq!(lines(&company).get(2..5).unwrap(), ["N:;;;;", "FN:Analytical Engines Ltd", "ORG:Analytical Engines Ltd"]);
|
||||
let mail = Contact { emails: vec![Labeled::new("home", "x@example.org")], department: "Only a department".into(), ..Default::default() };
|
||||
assert_eq!(lines(&mail).get(3..6).unwrap(), ["FN:x@example.org", "ORG:;Only a department", "EMAIL;TYPE=INTERNET,HOME:x@example.org"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_label_is_a_type() {
|
||||
let c = Contact {
|
||||
emails: ["work", "home", "other", "mobile", "Work"].map(|l| Labeled::new(l, "a@example.org")).to_vec(),
|
||||
phones: ["mobile", "work", "home", "fax", "pager", "other", "HOME"].map(|l| Labeled::new(l, "1")).to_vec(),
|
||||
..Default::default()
|
||||
};
|
||||
let got = lines(&c);
|
||||
assert_eq!(
|
||||
got.get(4..20).unwrap(),
|
||||
[
|
||||
"EMAIL;TYPE=INTERNET,WORK:a@example.org",
|
||||
"EMAIL;TYPE=INTERNET,HOME:a@example.org",
|
||||
"EMAIL;TYPE=INTERNET,OTHER:a@example.org",
|
||||
"item1.EMAIL;TYPE=INTERNET:a@example.org",
|
||||
"item1.X-ABLabel:mobile",
|
||||
"item2.EMAIL;TYPE=INTERNET:a@example.org",
|
||||
"item2.X-ABLabel:Work",
|
||||
"TEL;TYPE=CELL:1",
|
||||
"TEL;TYPE=WORK:1",
|
||||
"TEL;TYPE=HOME:1",
|
||||
"TEL;TYPE=FAX:1",
|
||||
"TEL;TYPE=PAGER:1",
|
||||
"TEL;TYPE=OTHER:1",
|
||||
"item3.TEL:1",
|
||||
"item3.X-ABLabel:HOME",
|
||||
"END:VCARD",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_end_in_crlf_and_stay_within_75_octets() {
|
||||
let mut c = nadia();
|
||||
c.notes = "Ünïcödé — 東京 🎉 ".repeat(60);
|
||||
c.display_name = "é".repeat(200);
|
||||
let out = write(&[&c, &c]);
|
||||
assert_eq!(out.matches("BEGIN:VCARD\r\n").count(), 2);
|
||||
assert!(!out.replace("\r\n", "").contains(['\r', '\n']));
|
||||
assert!(out.split("\r\n").all(|line| line.len() <= 75), "{out}");
|
||||
assert!(out.contains("\r\n "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_cannot_inject_lines() {
|
||||
let c = Contact {
|
||||
display_name: "Ada\r\nEND:VCARD\r\nBEGIN:VCARD".into(),
|
||||
first: "A\nTEL:666".into(),
|
||||
nickname: "x\rEMAIL:evil@example.org".into(),
|
||||
emails: vec![Labeled::new("work\r\nTEL:1", "a@example.org\r\nTEL:2")],
|
||||
notes: "one\r\nEND:VCARD\rtwo\u{0}".into(),
|
||||
categories: vec!["a\nb".into()],
|
||||
uid: "u\nUID:2".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let got = lines(&c);
|
||||
assert_eq!(got.iter().filter(|l| l.starts_with("BEGIN:") || l.starts_with("END:")).count(), 2, "{got:?}");
|
||||
assert!(!got.iter().any(|l| l.starts_with("TEL")), "{got:?}");
|
||||
assert_eq!(got.iter().filter(|l| l.starts_with("UID") || l.contains("EMAIL")).count(), 3, "{got:?}");
|
||||
assert!(got.contains(&"FN:Ada END:VCARD BEGIN:VCARD".to_string()));
|
||||
assert!(got.contains(&"NOTE:one\\nEND:VCARD\\ntwo".to_string()));
|
||||
assert!(got.contains(&"item1.X-ABLabel:work TEL:1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_birthdays_are_written_as_real_dates() {
|
||||
for hostile in [
|
||||
NaiveDate::MIN,
|
||||
NaiveDate::MAX,
|
||||
NaiveDate::from_ymd_opt(0, 1, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(-4, 2, 29).unwrap(),
|
||||
NaiveDate::from_ymd_opt(10_000, 2, 29).unwrap(),
|
||||
] {
|
||||
let c = Contact { birthday: Some(hostile), ..Default::default() };
|
||||
let back = crate::parse(&write(&[&c]));
|
||||
assert!(back[0].birthday.is_some(), "{hostile:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user