Implement RFC 2426 text escaping consistently across vCard 3 serialization and parsing. On serialization, escape backslashes, newlines, semicolons, and commas for simple text properties, structured text components, and list-valued text properties so generated FN, N, ADR, ORG, CATEGORIES, and related properties are spec-compliant on the wire. On parsing, decode escaped text for the properties that were previously read as raw values: FN, NICKNAME, LABEL, MAILER, TITLE, ROLE, PRODID, and SORT-STRING. This preserves existing structured-text parsing for N, ADR, NOTE, ORG, and CATEGORIES while fixing the direct raw-value mismatch identified in the review. Add regression coverage for both directions: parsing escaped text values and serializing escaped simple, structured, and list text values. AI-Assisted: yes AI-Tool: OpenAI Codex / gpt-5.4 xhigh
267 lines
8.7 KiB
Nim
267 lines
8.7 KiB
Nim
import std/[options, strutils, times, unittest]
|
|
import zero_functional
|
|
|
|
import vcard
|
|
import vcard/vcard3
|
|
|
|
template vcard3Doc(lines: varargs[string]): string =
|
|
"BEGIN:VCARD\r\n" &
|
|
lines.join("\r\n") &
|
|
"\r\nEND:VCARD\r\n"
|
|
|
|
proc parseSingleVCard3(content: string): VCard3 =
|
|
cast[VCard3](parseVCards(content)[0])
|
|
|
|
proc newMinimalVCard3(): VCard3 =
|
|
result = VCard3(parsedVersion: VCardV3)
|
|
result.add(newVC3_Fn("John Smith"))
|
|
result.add(newVC3_N(family = @["Smith"], given = @["John"]))
|
|
|
|
suite "vcard/vcard3":
|
|
|
|
test "vcard3/private tests":
|
|
runVcard3PrivateTests()
|
|
|
|
let jdbVCard = readFile("tests/jdb.vcf")
|
|
|
|
test "parseVCard3":
|
|
check parseVCards(jdbVCard).len == 1
|
|
|
|
test "parseVCard3File":
|
|
check parseVCardsFromFile("tests/jdb.vcf").len == 1
|
|
|
|
# TODO: remove cast after finishing VCard4 implementation
|
|
let jdb = cast[VCard3](parseVCards(jdbVCard)[0])
|
|
|
|
test "email is parsed correctly":
|
|
check:
|
|
jdb.email.len == 7
|
|
jdb.email[0].value == "jonathan@jdbernard.com"
|
|
jdb.email[0].emailType.contains("pref")
|
|
jdb.email[0].emailType.contains("home")
|
|
jdb.email[1].value == "jdb@jdb-software.com"
|
|
jdb.email[1].emailType.contains("work")
|
|
jdb.email[2].group.isSome
|
|
jdb.email[2].group.get == "email2"
|
|
jdb.email[6].value == "jbernard@vectra.ai"
|
|
jdb.email[6].emailType.contains("work")
|
|
|
|
test "tel is parsed correctly":
|
|
check:
|
|
jdb.tel.len == 2
|
|
jdb.tel[0].value == "(512) 777-1602"
|
|
jdb.tel[0].telType.contains("CELL")
|
|
|
|
test "RFC2426 Author's VCards":
|
|
let vcardsStr =
|
|
"BEGIN:vCard\r\n" &
|
|
"VERSION:3.0\r\n" &
|
|
"FN:Frank Dawson\r\n" &
|
|
"ORG:Lotus Development Corporation\r\n" &
|
|
"ADR;TYPE=WORK,POSTAL,PARCEL:;;6544 Battleford Drive\r\n" &
|
|
" ;Raleigh;NC;27613-3502;U.S.A.\r\n" &
|
|
"TEL;TYPE=VOICE,MSG,WORK:+1-919-676-9515\r\n" &
|
|
"TEL;TYPE=FAX,WORK:+1-919-676-9564\r\n" &
|
|
"EMAIL;TYPE=INTERNET,PREF:Frank_Dawson@Lotus.com\r\n" &
|
|
"EMAIL;TYPE=INTERNET:fdawson@earthlink.net\r\n" &
|
|
"URL:http://home.earthlink.net/~fdawson\r\n" &
|
|
"END:vCard\r\n" &
|
|
"\r\n" &
|
|
"\r\n" &
|
|
"BEGIN:vCard\r\n" &
|
|
"VERSION:3.0\r\n" &
|
|
"FN:Tim Howes\r\n" &
|
|
"ORG:Netscape Communications Corp.\r\n" &
|
|
"ADR;TYPE=WORK:;;501 E. Middlefield Rd.;Mountain View;\r\n" &
|
|
" CA; 94043;U.S.A.\r\n" &
|
|
"TEL;TYPE=VOICE,MSG,WORK:+1-415-937-3419\r\n" &
|
|
"TEL;TYPE=FAX,WORK:+1-415-528-4164\r\n" &
|
|
"EMAIL;TYPE=INTERNET:howes@netscape.com\r\n" &
|
|
"END:vCard\r\n"
|
|
|
|
let vcards = parseVCards(vcardsStr)
|
|
check:
|
|
vcards.len == 2
|
|
cast[VCard3](vcards[0]).fn.value == "Frank Dawson"
|
|
cast[VCard3](vcards[0]).email.len == 2
|
|
(cast[VCard3](vcards[0]).email --> find(it.emailType.contains("PREF"))).isSome
|
|
|
|
test "spec: parser rejects cards missing VERSION":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;"))
|
|
|
|
test "spec: parser rejects cards missing FN":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"N:Smith;John;;;"))
|
|
|
|
test "spec: parser rejects cards missing N":
|
|
expect(VCardParsingError):
|
|
discard parseVCards(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith"))
|
|
|
|
test "spec: simple text values decode RFC 2426 escapes when parsing":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
r"FN:Jane\, Smith\; Esq.\\Office\nSecond line",
|
|
"N:Smith;Jane;;;"))
|
|
|
|
check parsed.fn.value == "Jane, Smith; Esq.\\Office\nSecond line"
|
|
|
|
test "spec: affected text properties decode RFC 2426 escapes when parsing":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
r"NICKNAME:Johnny\, Jr.\nTwo",
|
|
r"LABEL:123 Main St.\nSuite 100\; Mail Stop",
|
|
r"MAILER:Mailer\\Pro",
|
|
r"TITLE:Lead\; Engineer",
|
|
r"ROLE:Ops\, Support",
|
|
r"PRODID:-//Example\\Corp//EN",
|
|
r"SORT-STRING:Smith\, John"))
|
|
|
|
check:
|
|
parsed.nickname.isSome
|
|
parsed.nickname.get.value == "Johnny, Jr.\nTwo"
|
|
parsed.label.len == 1
|
|
parsed.label[0].value == "123 Main St.\nSuite 100; Mail Stop"
|
|
parsed.mailer.len == 1
|
|
parsed.mailer[0].value == "Mailer\\Pro"
|
|
parsed.title.len == 1
|
|
parsed.title[0].value == "Lead; Engineer"
|
|
parsed.role.len == 1
|
|
parsed.role[0].value == "Ops, Support"
|
|
parsed.prodid.isSome
|
|
parsed.prodid.get.value == "-//Example\\Corp//EN"
|
|
parsed.sortstring.isSome
|
|
parsed.sortstring.get.value == "Smith, John"
|
|
|
|
test "spec: simple text values escape special characters when serializing":
|
|
let vc = newMinimalVCard3()
|
|
vc.set(newVC3_Fn("Jane, Smith; Esq.\\Office\nSecond line"))
|
|
|
|
check ($vc).contains(r"FN:Jane\, Smith\; Esq.\\Office\nSecond line")
|
|
|
|
test "spec: structured text values escape special characters when serializing":
|
|
let vc = VCard3(parsedVersion: VCardV3)
|
|
vc.add(newVC3_Fn("John Smith"))
|
|
vc.add(newVC3_N(
|
|
family = @["Smith, Sr."],
|
|
given = @["John;Jack"],
|
|
additional = @["Back\\Slash"],
|
|
prefixes = @["Dr.\nProf."],
|
|
suffixes = @["III"]))
|
|
|
|
check ($vc).contains(r"N:Smith\, Sr.;John\;Jack;Back\\Slash;Dr.\nProf.;III")
|
|
|
|
test "spec: list text values escape special characters when serializing":
|
|
let vc = newMinimalVCard3()
|
|
vc.add(newVC3_Categories(@["alpha,beta", "semi;colon", "slash\\value"]))
|
|
|
|
check ($vc).contains(r"CATEGORIES:alpha\,beta,semi\;colon,slash\\value")
|
|
|
|
test "spec: inline binary values round-trip without double encoding":
|
|
let payload = "aGVsbG8="
|
|
let serialized = $parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"PHOTO;ENCODING=b;TYPE=JPEG:" & payload,
|
|
"LOGO;ENCODING=b;TYPE=GIF:" & payload,
|
|
"SOUND;ENCODING=b;TYPE=WAVE:" & payload,
|
|
"KEY;ENCODING=b;TYPE=PGP:" & payload))
|
|
|
|
check:
|
|
serialized.contains("PHOTO;ENCODING=b;TYPE=JPEG:" & payload)
|
|
serialized.contains("LOGO;ENCODING=b;TYPE=GIF:" & payload)
|
|
serialized.contains("SOUND;ENCODING=b;TYPE=WAVE:" & payload)
|
|
serialized.contains("KEY;ENCODING=b;TYPE=PGP:" & payload)
|
|
|
|
test "spec: uri-backed binary properties round-trip as uris":
|
|
let serialized = $parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"PHOTO;VALUE=uri:http://example.test/photo.jpg",
|
|
"LOGO;VALUE=uri:http://example.test/logo.gif",
|
|
"SOUND;VALUE=uri:http://example.test/sound.wav",
|
|
"KEY;VALUE=uri:http://example.test/key.asc"))
|
|
|
|
check:
|
|
serialized.contains("PHOTO;VALUE=uri:http://example.test/photo.jpg")
|
|
serialized.contains("LOGO;VALUE=uri:http://example.test/logo.gif")
|
|
serialized.contains("SOUND;VALUE=uri:http://example.test/sound.wav")
|
|
serialized.contains("KEY;VALUE=uri:http://example.test/key.asc")
|
|
|
|
test "spec: quoted parameter values are accepted":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN;LANGUAGE=\"en\":John Smith",
|
|
"N:Smith;John;;;"))
|
|
|
|
check parsed.fn.language == some("en")
|
|
|
|
test "spec: PROFILE is exposed as the standard property type":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"PROFILE:VCARD",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;"))
|
|
|
|
check parsed.profile.isSome
|
|
|
|
test "spec: AGENT uri values survive parse and serialize":
|
|
let serialized = $parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"AGENT;VALUE=uri:mailto:assistant@example.com"))
|
|
|
|
check serialized.contains("AGENT;VALUE=uri:mailto:assistant@example.com")
|
|
|
|
test "spec: folded lines may continue with horizontal tab":
|
|
let parsed = parseSingleVCard3(
|
|
"BEGIN:VCARD\r\n" &
|
|
"VERSION:3.0\r\n" &
|
|
"FN:John Smith\r\n" &
|
|
"N:Smith;John;;;\r\n" &
|
|
"NOTE:one two \r\n" &
|
|
"\tthree four\r\n" &
|
|
"END:VCARD\r\n")
|
|
|
|
check:
|
|
parsed.note.len == 1
|
|
parsed.note[0].value == "one two three four"
|
|
|
|
test "spec: group names may contain hyphens":
|
|
let parsed = parseSingleVCard3(vcard3Doc(
|
|
"VERSION:3.0",
|
|
"FN:John Smith",
|
|
"N:Smith;John;;;",
|
|
"item-1.EMAIL:test@example.com"))
|
|
|
|
check:
|
|
parsed.email.len == 1
|
|
parsed.email[0].group == some("item-1")
|
|
|
|
test "spec: REV with VALUE=date serializes as a date":
|
|
let vc = newMinimalVCard3()
|
|
vc.add(newVC3_Rev(
|
|
value = parse("2000-01-02", "yyyy-MM-dd"),
|
|
valueType = some("date")))
|
|
|
|
check ($vc).contains("REV;VALUE=date:2000-01-02")
|
|
|
|
test "spec: KEY defaults to text rather than uri":
|
|
let vc = newMinimalVCard3()
|
|
vc.add(newVC3_Key("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC"))
|
|
|
|
check:
|
|
($vc).contains("KEY:ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC")
|
|
not ($vc).contains("KEY;VALUE=uri:")
|