From f3944ba991788b44056b6a4fea1284e50a451bcd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:50:38 -0500 Subject: [PATCH] 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> --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 5 + crates/vcard/Cargo.toml | 19 + crates/vcard/README.md | 48 +++ crates/vcard/src/key.rs | 294 +++++++++++++ crates/vcard/src/key_tests.rs | 417 ++++++++++++++++++ crates/vcard/src/lib.rs | 26 ++ crates/vcard/src/lines.rs | 429 +++++++++++++++++++ crates/vcard/src/model.rs | 127 ++++++ crates/vcard/src/read.rs | 778 ++++++++++++++++++++++++++++++++++ crates/vcard/src/tests.rs | 391 +++++++++++++++++ crates/vcard/src/text.rs | 239 +++++++++++ crates/vcard/src/write.rs | 326 ++++++++++++++ 14 files changed, 3109 insertions(+) create mode 100644 crates/vcard/Cargo.toml create mode 100644 crates/vcard/README.md create mode 100644 crates/vcard/src/key.rs create mode 100644 crates/vcard/src/key_tests.rs create mode 100644 crates/vcard/src/lib.rs create mode 100644 crates/vcard/src/lines.rs create mode 100644 crates/vcard/src/model.rs create mode 100644 crates/vcard/src/read.rs create mode 100644 crates/vcard/src/tests.rs create mode 100644 crates/vcard/src/text.rs create mode 100644 crates/vcard/src/write.rs diff --git a/Cargo.lock b/Cargo.lock index 21909a8af..9ab6b0dd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 65f501457..9adfeca74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index ab45c1a99..58d0758db 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,11 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Option { + 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 { + 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) -> Option { + 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) -> Option { + 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> { + 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, Vec)> { + 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 { + 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 { + 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 { + 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))) +} diff --git a/crates/vcard/src/key_tests.rs b/crates/vcard/src/key_tests.rs new file mode 100644 index 000000000..18bfe2291 --- /dev/null +++ b/crates/vcard/src/key_tests.rs @@ -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 { + 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 { + 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 { + 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::>(), [1, 1, 0, 0, 0]); + assert_eq!(parse(&text).iter().map(|c| c.keys.len()).sum::(), 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 = (0..ContactKey::MAX_BYTES - 32).map(|i| (i % 251) as u8).collect(); + let cert = certificate(&[1], &signature); + let body: Vec = (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 { + let bytes = || proptest::collection::vec(any::(), 1..200); + let user_ids = proptest::collection::vec(proptest::collection::vec(any::(), 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 = 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::(), + bytes in proptest::collection::vec(any::(), 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::>()), 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::(), 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()); + } + } +} diff --git a/crates/vcard/src/lib.rs b/crates/vcard/src/lib.rs new file mode 100644 index 000000000..f2fc2fd6a --- /dev/null +++ b/crates/vcard/src/lib.rs @@ -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; diff --git a/crates/vcard/src/lines.rs b/crates/vcard/src/lines.rs new file mode 100644 index 000000000..7507026ef --- /dev/null +++ b/crates/vcard/src/lines.rs @@ -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 { + 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 { + 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 { + 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, +} + +/// 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, + /// 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 { + // 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 { + 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 { + 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::(), v21 in proptest::prelude::any::()) { + for line in lines(&text, v21) { + let _ = ContentLine::parse(&line).map(|l| l.types()); + } + } + } +} diff --git a/crates/vcard/src/model.rs b/crates/vcard/src/model.rs new file mode 100644 index 000000000..2fd6b4fa6 --- /dev/null +++ b/crates/vcard/src/model.rs @@ -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, value: impl Into) -> 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::>() + .join(" "); + [self.street.as_str(), city.as_str(), self.country.as_str()].into_iter().filter(|p| !p.trim().is_empty()).collect::>().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, + /// 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, + pub phones: Vec, + pub addresses: Vec, + pub websites: Vec, + pub birthday: Option, + pub notes: String, + pub categories: Vec, + /// `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, + /// `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::>().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() + } +} diff --git a/crates/vcard/src/read.rs b/crates/vcard/src/read.rs new file mode 100644 index 000000000..faa117f45 --- /dev/null +++ b/crates/vcard/src/read.rs @@ -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 { + 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 { + 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 = 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 { + 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, +} + +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::>().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::>().join(", "); + let address = PostalAddress { + label: place_label(&line.types()).into(), + street, + city: part(3), + region: part(4), + postal_code: part(5), + country: part(6), + }; + if !address.is_empty() { + self.addresses.push((line.group.clone(), address)); + } + } + "URL" if contact.websites.len() < MAX_ITEMS => { + let value = one(line); + if !value.is_empty() { + contact.websites.push(value); + } + } + "BDAY" if contact.birthday.is_none() => contact.birthday = birthday(&line.value), + "NOTE" => { + let note = multi(&unescape(&decoded(line))); + if !note.is_empty() { + if !contact.notes.is_empty() { + contact.notes.push('\n'); + } + contact.notes.push_str(¬e); + } + } + "CATEGORIES" => { + let value = decoded(line); + let room = MAX_CATEGORIES.saturating_sub(contact.categories.len()); + contact + .categories + .extend(split(&value, ',', MAX_CATEGORIES).into_iter().map(|c| single(&unescape(c))).filter(|c| !c.is_empty()).take(room)); + } + "UID" if contact.uid.is_empty() => contact.uid = one(line), + "KEY" if contact.keys.len() < Contact::MAX_KEYS && *key_room > 0 => { + if let Some(key) = crate::key::read(line) { + let cost = key.data.len().saturating_add(key.uri.len()); + if cost <= *key_room && !contact.keys.contains(&key) { + *key_room = key_room.saturating_sub(cost); + contact.keys.push(key); + } + } + } + "X-FAVORITE" | "X-MAILCRAFT-FAVORITE" => { + contact.favorite |= matches!(line.value.trim().to_ascii_uppercase().as_str(), "TRUE" | "YES" | "1") + } + "X-ABLABEL" if !line.group.is_empty() && self.labels.len() < MAX_LABELS => { + let label = one(line); + // The built-in labels of the program that invented this are wrapped: `_$!!$_`. + 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 { + 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 { + 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:_$!!$_\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:_$!!$_\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 = 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:_$!!$_\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::>(); + 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::>(), + [("", "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 = "\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::>()); + 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::>(), ["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::>(), + [("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::>(), (0..50).map(|i| format!("Card {i}")).collect::>()); + } + + #[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::(), + )); + 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); + } +} diff --git a/crates/vcard/src/tests.rs b/crates/vcard/src/tests.rs new file mode 100644 index 000000000..22ee050a0 --- /dev/null +++ b/crates/vcard/src/tests.rs @@ -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 { + parse(&write(&contacts.iter().collect::>())) +} + +fn sample_contacts() -> Vec { + 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::>()); + 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::>()); + assert_eq!(parse(&text), back); + assert_eq!(write(&parse(&text).iter().collect::>()), 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::>()); +} + +/// One line of text as a contact field holds it: printable, trimmed. +fn line() -> impl Strategy { + 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 { + (line(), "[!-~]").prop_map(|(line, fallback)| if line.is_empty() { fallback } else { line }) +} + +fn label(known: &'static [&'static str]) -> impl Strategy { + prop_oneof![proptest::sample::select(known).prop_map(str::to_string), Just(String::new()), line()] +} + +fn date() -> impl Strategy { + (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 { + // 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 { + 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::(), 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 { + 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=", + "_$!!$_", + "_$!<", + ]) + .prop_map(str::to_string), + "\\PC{0,6}", + any::(), + ]; + proptest::collection::vec(piece, 0..60).prop_map(|pieces| pieces.concat()) +} + +fn valid_text() -> String { + write(&sample_contacts().iter().collect::>()) +} + +/// Whatever the reader gives must also be writable and readable again. +fn survives(text: &str) -> Vec { + let contacts = parse(text); + let again = write(&contacts.iter().collect::>()); + 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 = 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::(), notes in any::(), label in any::(), value in any::(), category in any::()) { + 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::>()); + prop_assert!(!once[0].display_name.contains(['\r', '\n']) && !once[0].notes.contains('\r')); + } + + #[test] + fn arbitrary_text_never_panics(text in any::()) { + survives(&text); + } + + #[test] + fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..2048)) { + let contacts = crate::parse_bytes(&bytes); + let _ = parse(&write(&contacts.iter().collect::>())); + } + + #[test] + fn card_shaped_bytes_never_panic(text in soup(), high in proptest::collection::vec((any::(), 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::(), 0usize..40, 0u8..5, "\\PC{0,8}|[\r\n:;,=\"\\\\ .]{1,4}"), 1..6)) { + let mut chars: Vec = 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 = 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..]); + } +} diff --git a/crates/vcard/src/text.rs b/crates/vcard/src/text.rs new file mode 100644 index 000000000..a82228b38 --- /dev/null +++ b/crates/vcard/src/text.rs @@ -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::>().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 { + 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 { + 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::(), 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'])); + } + } +} diff --git a/crates/vcard/src/write.rs b/crates/vcard/src/write.rs new file mode 100644 index 000000000..3d601eca3 --- /dev/null +++ b/crates/vcard/src/write.rs @@ -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 = 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 { + 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:?}"); + } + } +}