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:
Dylan M. TaylorandMartin Ball authored and GitHub committed 2026-11-11 18:50:38 -05:00
1 parent 85f30007d7
commit f3944ba991
14 files changed
+3109

No files matched your search

Generated
+9
View File
@@ -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"
+1
View File
@@ -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" }
+5
View File
@@ -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.
+19
View File
@@ -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 }
+48
View File
@@ -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.
+294
View File
@@ -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)))
}
+417
View File
@@ -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());
}
}
}
+26
View File
@@ -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;
+429
View File
@@ -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());
}
}
}
}
+127
View File
@@ -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()
}
}
+778
View File
@@ -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(&note);
}
}
"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);
}
}
+391
View File
@@ -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..]);
}
}
+239
View File
@@ -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']));
}
}
}
+326
View File
@@ -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:?}");
}
}
}