1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
require_relative "../../test_helper"
require_relative "../../photo_card"
require "base64"
require "hegel"
require "pro_tacts/vcard/parser"
class VCardParserTest < Minitest::Test
include Hegel::Syntax::Methods
def lines(card)
ProTacts::VCard::Parser.lines(card)
end
# What the card's lines read, which for a card whose every line reads
# is the card. The parser hands back lines and only lines, so folding
# them down to structure is the caller's move — this is the one every
# test below that asks about a property makes.
def properties(card)
lines(card).filter_map { it.property }
end
# The error the one line of a card carried, for the tests that are
# about a line that will not read.
def error(card)
lines(card).fetch(0).error
end
# A card as it arrives: CRLF line breaks, trailing break included.
def card(*lines)
lines.join("\r\n") + "\r\n"
end
## Content lines
def test_every_line_comes_back_as_a_property
parsed = properties(card(
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Test Contact",
"END:VCARD",
))
assert_equal %w[BEGIN VERSION FN END], parsed.map { it.name }
assert_equal %w[VCARD 3.0], parsed.first(2).map { it.value }
end
def test_a_value_may_contain_colons_and_spaces
property = properties(card("URL:https://example.com/a:b")).first
assert_equal "https://example.com/a:b", property.value
end
def test_a_value_may_be_empty
property = properties(card("NOTE:")).first
assert_equal "NOTE", property.name
assert_equal "", property.value
end
def test_a_value_keeps_its_escaping
property = properties(card("FN:Smith\\, John\; Jr.")).first
assert_equal "Smith\\, John\; Jr.", property.value
end
def test_a_group_is_split_off_the_name
parsed = properties(card("item1.X-ABLabel:_$!<Home>!$_", "X-ABADR:us"))
assert_equal ["item1", nil], parsed.map { it.group }
assert_equal %w[X-ABLabel X-ABADR], parsed.map { it.name }
end
def test_blank_lines_are_skipped
assert_equal %w[FN UID], properties("FN:A\r\n\r\nUID:b\r\n").map { it.name }
end
def test_a_final_line_break_is_optional
assert_equal %w[END], properties("END:VCARD").map { it.name }
end
# Contacts sends CRLF, but a card that reached the store some other way
# should not be unreadable over a line ending.
def test_bare_line_feeds_are_accepted
assert_equal %w[FN UID], properties("FN:A\nUID:b\n").map { it.name }
end
## Parameters
def test_a_parameter_is_a_name_and_a_value
property = properties(card("TEL;TYPE=work:+1-555-1234")).first
assert_equal [%w[TYPE work]], property.parameters
assert_equal "+1-555-1234", property.value
end
# RFC 2426 section 3.3.1 permits both spellings, so both parse to the
# same pairs. Contacts writes the first (see
# docs/plans/2026-08-24-corrections-from-the-first-write.md).
def test_a_repeated_parameter_and_a_value_list_agree
repeated = properties(card("ADR;type=HOME;type=pref:;;1 Main St;;;;")).first
listed = properties(card("ADR;type=HOME,pref:;;1 Main St;;;;")).first
assert_equal [%w[type HOME], %w[type pref]], repeated.parameters
assert_equal repeated.parameters, listed.parameters
end
# A quoted value is how a parameter carries the delimiters that would
# otherwise end it.
def test_a_quoted_parameter_value_keeps_its_delimiters
property = properties(card('PHOTO;X-NOTE="a;b:c,d":data')).first
assert_equal [["X-NOTE", "a;b:c,d"]], property.parameters
assert_equal "data", property.value
end
def test_an_empty_parameter_value_is_allowed
assert_equal [["TYPE", ""]], properties(card("TEL;TYPE=:+1-555-1234")).first.parameters
end
## Folding
# Unfolding takes the line break and the one whitespace character after
# it, and nothing else: a fold falls between two octets, not between
# two words, so a space either side of it is content.
def test_a_folded_line_is_one_property
property = properties(card("NOTE:supercalifragilistic", " expialidocious")).first
assert_equal "supercalifragilisticexpialidocious", property.value
end
# A fold lands wherever 75 octets falls, which is not always in the
# value: Contacts does not look at what it is splitting.
def test_a_fold_inside_the_parameters_is_unfolded_too
property = properties(card("TEL;TY", " PE=work:+1-555-1234")).first
assert_equal [%w[TYPE work]], property.parameters
assert_equal "+1-555-1234", property.value
end
# A line of nothing but whitespace is a fold by the letter of RFC 2426
# section 2.6 — a line break followed by a space — so it joins the line
# above rather than standing alone, and whatever whitespace is left
# over becomes part of that value.
def test_a_whitespace_only_line_folds_into_the_one_above_it
parsed = properties(card("FN:A", " ", "UID:b"))
assert_equal %w[FN UID], parsed.map { it.name }
assert_equal "A ", parsed.first.value
end
# With no line above it to fold into, the same whitespace is simply not
# a content line. One stray line at the top of a card therefore costs
# the whole card its index entry — it is still served, from its bytes.
def test_a_leading_whitespace_only_line_does_not_read
assert_kind_of ProTacts::VCard::Parser::ParseError, error(card(" ", "FN:A"))
end
def test_a_continuation_may_start_with_a_tab
assert_equal "abcd", properties(card("NOTE:ab", "\tcd")).first.value
end
## Pictures
# The two shapes a picture arrives in, built as captured
# (test/photo_card.rb, from the exchange log): the parameter section on
# one physical line however long, the base64 payload folded one
# break below it. The parser reads both without a length ceiling
# because the line split comes before the token scan — nothing here
# has to know how long any line is.
def test_a_photos_parameters_and_payload_read_whole
image = PhotoCard.image(600)
photo = PhotoCard.property(PhotoCard::PHOTO_PARAMETERS, image)
property = properties(card(photo)).first
assert_equal PhotoCard::PHOTO_PARAMETERS.map { it.split("=", 2) }, property.parameters
assert_equal Base64.strict_encode64(image), property.value
end
# The memoji shape adds VND-63-MEMOJI-DETAILS, whose base64 plist
# runs the parameter section to 1,683 octets on one line — past the
# 75-octet fold by an order of magnitude, with the payload folded
# correctly beneath it.
def test_a_memojis_unfolded_parameter_line_reads
image = PhotoCard.image(600)
memoji = PhotoCard.property(PhotoCard::MEMOJI_PARAMETERS, image)
property = properties(card(memoji)).first
assert_equal PhotoCard::MEMOJI_PARAMETERS.map { it.split("=", 2) }, property.parameters
assert_equal Base64.strict_encode64(image), property.value
end
# Any image and any memoji details, folded the client's way, read
# back whole — the parser's correctness rests on the fold shape,
# not on the two captured sizes.
def test_a_photo_folded_like_the_client_reads_back_whole
Hegel.test do |tc|
image = tc.draw(text(max_size: 4_000))
details = tc.draw(text(max_size: 2_000))
parameters = [
PhotoCard::MEMOJI_CROP,
"VND-63-MEMOJI-DETAILS=#{Base64.strict_encode64(details)}",
"ENCODING=b",
"TYPE=JPEG",
]
property = properties(card(PhotoCard.property(parameters, image))).first
raise "payload did not survive" unless property.value == Base64.strict_encode64(image)
raise "parameters did not survive" unless property.parameters == parameters.map { it.split("=", 2) }
end
end
## Lines
# lines pairs each property with the exact bytes it came from — the
# fold travels with its line, and the verbatims join back to the card.
def test_lines_pair_properties_with_verbatim_bytes
card = "BEGIN:VCARD\r\nBDAY:1985-04-\r\n 12\r\nEND:VCARD\r\n"
lines = ProTacts::VCard::Parser.lines(card)
assert_equal %w[BEGIN BDAY END], lines.map { it.property&.name }
assert_equal "1985-04-12", lines.fetch(1).property&.value
assert_equal "BDAY:1985-04-\r\n 12\r\n", lines.fetch(1).verbatim
assert_equal card, lines.map(&:verbatim).join
end
# A bare CR ends a content line without ending a physical line, so
# it packs two properties into one line's bytes. No recorded macOS
# session sends one, and this server is built on that: the line is
# refused, its bytes kept, so nothing downstream can move a property
# and take another property's bytes along.
def test_a_bare_cr_packing_two_content_lines_is_refused
line = ProTacts::VCard::Parser.lines("FN:A\rNOTE:n\r\n").fetch(0)
assert_kind_of ProTacts::VCard::Parser::BrokenAssumption, line.error
assert_nil line.property
assert_equal "FN:A\rNOTE:n\r\n", line.verbatim
assert_empty properties("FN:A\rNOTE:n\r\n")
end
# A bare CR that ends the only content line on its line takes no
# second property with it, so there is nothing to refuse.
def test_a_bare_cr_ending_a_line_alone_still_reads
assert_equal %w[FN], properties("FN:A\r").map { it.name }
end
# Line breaks with nothing between them pack no second content line,
# whatever mix of CR, LF and CRLF ends a line: the line reads as its
# one property, and nothing reports a break (`NOTE:a\r\r\n` was the
# case this shrank to).
def test_a_run_of_line_breaks_ends_one_content_line
Hegel.test(test_cases: 1_000) do |tc|
value = tc.draw(from_regex("[^\\r\\n]{0,10}", fullmatch: true))
breaks = tc.draw(arrays(sampled_from(["\r", "\n", "\r\n"]), min_size: 1, max_size: 4)).join
walked = lines("NOTE:#{value}#{breaks}")
assert_equal [false], walked.map(&:broke_assumption?).uniq
assert_equal [value], walked.filter_map(&:property).map(&:value)
end
end
# BrokenAssumption is a ParseError: it reads no differently from a
# line that merely will not read, and the class exists so Store can
# report the break, not so anything can branch on it.
def test_a_broken_assumption_is_a_parse_error
assert_operator ProTacts::VCard::Parser::BrokenAssumption, :<, ProTacts::VCard::Parser::ParseError
assert_kind_of ProTacts::VCard::Parser::BrokenAssumption, error("BEGIN:VCARD\rEND:VCARD\r\n")
end
# broke_assumption? separates the break worth reporting from an
# ordinary bad line and from a line that read nothing at all.
def test_only_a_broken_assumption_reads_as_one
assert_equal [false, false, false], lines("FN:A\r\n\r\nBDAY;=;:\r\n").map { it.broke_assumption? }
assert lines("FN:A\rNOTE:n\r\n").fetch(0).broke_assumption?
end
# unreadable? is the other half of that split: the ordinary bad line
# reads as unreadable, the blank line and the break do not.
def test_only_a_plain_parse_failure_reads_as_unreadable
assert_equal [false, false, true], lines("FN:A\r\n\r\nBDAY;=;:\r\n").map { it.unreadable? }
assert_equal false, lines("FN:A\rNOTE:n\r\n").fetch(0).unreadable?
end
def test_a_line_that_will_not_read_carries_its_error_and_keeps_its_bytes
line = ProTacts::VCard::Parser.lines("BDAY;=;:\r\n").fetch(0)
assert_kind_of ProTacts::VCard::Parser::ParseError, line.error
assert_nil line.property
assert_equal "BDAY;=;:\r\n", line.verbatim
end
# A line that will not read costs its own reading and nothing else:
# the lines around it read, so the failure never travels past the
# line it happened on.
def test_a_line_that_will_not_read_leaves_the_rest_alone
read = lines("FN:A\r\nBDAY;=;:\r\nUID:b\r\n")
assert_equal ["FN", nil, "UID"], read.map { it.property&.name }
assert_equal [nil, ProTacts::VCard::Parser::ParseError, nil], read.map { it.error&.class }
end
# A blank line is a line — its bytes stay enumerable, so a rewrite
# carrying lines does not drop them — while it reads as no property,
# as the grammar lets a card carry blank lines between properties.
def test_a_blank_line_stays_a_line_but_reads_as_none
read = lines("FN:A\r\n\r\nUID:b\r\n")
assert_equal ["FN:A\r\n", "\r\n", "UID:b\r\n"], read.map(&:verbatim)
assert_nil read.fetch(1).property
assert_nil read.fetch(1).error
assert_equal %w[FN UID], properties("FN:A\r\n\r\nUID:b\r\n").map { it.name }
end
## Refusals
def test_a_line_without_a_colon_does_not_read
assert_match(/expected ':'/, error(card("FN")).message)
end
def test_a_line_with_no_name_does_not_read
assert_kind_of ProTacts::VCard::Parser::ParseError, error(card(":value"))
end
# vCard 2.1 allowed `TEL;HOME:...`; the 3.0 grammar does not, and
# guessing which parameter was meant would invent data.
def test_a_parameter_without_a_value_does_not_read
assert_match(/HOME has no value/, error(card("TEL;HOME:+1-555-1234")).message)
end
def test_bytes_that_are_not_a_card_do_not_read
assert_kind_of ProTacts::VCard::Parser::ParseError, error("{\"json\": true}\r\n")
end
## Property tests
# An independent unescaper, so a bug shared with the escaper cannot
# hide: reverses RFC 2426 section 2.4.2.
def unescape(text)
text.gsub(/\\(.)/) { ::Regexp.last_match(1) == "n" ? "\n" : ::Regexp.last_match(1) }
end
# The renderer normalizes CRLF and CR to \n before escaping, so the
# oracle applies the same normalization before comparing.
def normalize(text)
text.gsub(/\r\n|\r/, "\n")
end
# Folded input for #unfold to undo, to RFC 2426 section 2.6: physical
# lines of at most 75 octets, each continuation a single space. The
# walk is character-wise so a multibyte character is never split. It
# lives here rather than in VCard because nothing this server writes
# folds — a served card is stored bytes going out untouched — so the
# only caller a folder has ever had is this test.
LINE_LIMIT = 75
def fold(line)
return line if line.bytesize <= LINE_LIMIT
folded = +""
width = 0
line.each_char do |char|
if width + char.bytesize > LINE_LIMIT
folded << "\r\n "
width = 1
end
folded << char
width += char.bytesize
end
folded
end
# Folding and unfolding are inverses on a rendered line, which is what
# makes an index rebuilt from stored cards agree with what was served.
# The folds are the client's — nothing here writes one — so this is
# the read holding up its end of a round trip it only ever finishes.
def test_an_escaped_and_folded_value_parses_back
Hegel.test do |tc|
value = tc.draw(text(max_size: 300))
line = fold("NOTE:#{ProTacts::VCard.escape(value)}")
parsed = properties("#{line}\r\n")
raise "expected one property, got #{parsed.length}" unless parsed.length == 1
raise "value did not survive" unless unescape(parsed.first.value) == normalize(value)
end
end
def test_a_parameter_value_survives_a_round_trip
token = from_regex("[A-Za-z0-9-]{1,20}", fullmatch: true)
Hegel.test do |tc|
name = tc.draw(token)
values = tc.draw(arrays(token, min_size: 1, max_size: 4))
line = "TEL#{values.map { ";#{name}=#{it}" }.join}:+1-555-1234"
property = properties("#{line}\r\n").first
raise "parameters did not survive" unless property.parameters == values.map { [name, it] }
end
end
# Fragments a card is made of, the line breaks and the grammar's
# punctuation among them, joined in any order: the inputs a draw
# from all of Unicode almost never assembles into something
# card-shaped enough to reach a fold or a bare CR.
FRAGMENTS = ["BEGIN:VCARD", "END:VCARD", "item1.", "TEL", "x", ":", ";", "=", ",", "\"", ".",
"\r\n", "\n", "\r", " ", "\t", "é"].freeze #: Array[String]
# No read raises on a card's behalf: whatever the bytes, the walk
# comes back as lines, and between them they hold every byte, in
# order — the guarantee behind serving a card from its bytes however
# it reads.
def test_any_text_walks_into_lines_that_rejoin_as_it
card_like = arrays(sampled_from(FRAGMENTS), max_size: 40).map(&:join)
Hegel.test(test_cases: 1_000) do |tc|
card = tc.draw(one_of(text(max_size: 200), card_like))
assert_equal card, lines(card).map(&:verbatim).join
end
end
# RFC 2426 section 2.6 puts no constraint on where a fold lands, mid-
# token and mid-escape included, so a content line folded between
# any of its characters reads as the line unfolded.
def test_a_fold_anywhere_reads_as_the_line_unfolded
token = from_regex("[A-Za-z0-9-]{1,8}", fullmatch: true)
line = composite { |dtc|
parameters = dtc.draw(arrays(tuples(token, from_regex('[^\x00-\x1f\x7f";:,]{0,8}', fullmatch: true)), max_size: 3))
value = dtc.draw(from_regex('[^\r\n]{0,40}', fullmatch: true))
"#{dtc.draw(token)}#{parameters.map { |name, setting| ";#{name}=#{setting}" }.join}:#{value}"
}
Hegel.test(test_cases: 1_000) do |tc|
unfolded = tc.draw(line)
cuts = tc.draw(sets(integers(min_value: 1, max_value: unfolded.length - 1))) if unfolded.length > 1
fold = tc.draw(sampled_from(["\r\n ", "\r\n\t", "\n "]))
folded = unfolded.chars.each_with_index.map { |char, index| cuts&.include?(index) ? "#{fold}#{char}" : char }.join
assert_equal properties("#{unfolded}\r\n"), properties("#{folded}\r\n")
end
end
# One value, two readings, and only the caller knows which its property
# wants: N is structured, so the escaped "\;" is a character inside a
# component — read as text it comes back spelled like the separator
# between them.
def test_a_value_reads_as_text_or_as_components
property = properties(card("N:Smith\\; Jr.;John;;;")).fetch(0)
assert_equal "Smith; Jr.;John;;;", property.text
assert_equal ["Smith; Jr.", "John", "", "", ""], property.components
end
## Parameters
def test_a_parameter_reads_by_name_without_case
property = properties(card("TEL;type=work:+1-555-1234")).fetch(0)
assert_equal "work", property.parameter("TYPE")
end
def test_a_parameter_listing_several_values_reads_the_first
property = properties(card("TEL;TYPE=work,voice:+1-555-1234")).fetch(0)
assert_equal "work", property.parameter("TYPE")
end
def test_a_parameter_the_repeated_spelling_lists_reads_the_same
property = properties(card("TEL;TYPE=work;TYPE=voice:+1-555-1234")).fetch(0)
assert_equal "work", property.parameter("TYPE")
end
def test_a_parameter_the_property_does_not_carry_reads_as_nil
property = properties(card("TEL:+1-555-1234")).fetch(0)
assert_nil property.parameter("TYPE")
end
end