vcard: a line folded inside its name still names it
Line#names? matched the line's bytes, so `NO\r\n TE:x` named nothing
while the parser read it as a NOTE, and replace, extract,
Contact#rows, assert_one_note and Merge's renumbering all missed it.
A line with a fold before its first `;` or `:` is now read unfolded;
every other line still matches on its bytes, so a folded PHOTO costs
no unfold.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 2d160af..c931db8 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -139,14 +139,23 @@ module ProTacts
# listing of contacts allocated.
NAME_PATTERNS = {} #: Hash[String, Regexp]
+ # A fold before the line's first `;` or `:`, inside its group or
+ # name, where the bytes alone would hide the name the parser reads.
+ HEAD_FOLD = /\A[^;:]*?(?:\r\n|[\r\n])[ \t]/ #: Regexp
+
# Whether this line names the property, a group prefix
# (`item1.BDAY`) counting as naming it. Asked of the line rather
# than the property because a line that will not parse still has
- # a name in it.
+ # a name in it. A fold is no part of the name (RFC 2426 section
+ # 2.6), so a line folded inside it is read unfolded — only that
+ # line, the rest matching on their bytes without allocating, a
+ # folded PHOTO's base64 included.
#: (String name) -> bool
def names?(name)
pattern = NAME_PATTERNS[name] ||= /\A(?:[A-Za-z0-9-]+\.)?#{Regexp.escape(name)}[;:]/i
- verbatim.match?(pattern)
+ return true if verbatim.match?(pattern)
+
+ verbatim.match?(HEAD_FOLD) && Parser.unfold(verbatim).match?(pattern)
end
# Whether this line broke one of the parser's assumptions rather
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index 06ef6d9..1ef49b7 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -142,6 +142,27 @@ class VCardLinesTest < Minitest::Test
assert_equal "BDAY;=;:\r\n", unparsed.verbatim
end
+ # Whether a line names a property is whether the parser reads it as
+ # that property, wherever a fold falls — inside the group or the name
+ # included, which the line's bytes alone would hide (RFC 2426
+ # section 2.6).
+ def test_names_agrees_with_the_parse_wherever_a_fold_falls
+ token = from_regex("[A-Za-z0-9-]{1,6}", fullmatch: true)
+ Hegel.test(test_cases: 1_000) do |tc|
+ group = tc.draw(optional(token))
+ name = tc.draw(one_of(token, sampled_from(%w[NOTE ADR BDAY TEL])))
+ text = "#{"#{group}." if group}#{name}#{tc.draw(sampled_from([":", ";TYPE=home:"]))}#{tc.draw(from_regex("[a-z;:,]{0,8}", fullmatch: true))}"
+ folds = tc.draw(sets(integers(min_value: 1, max_value: text.length - 1), max_size: 3)).sort.reverse
+ eol = tc.draw(sampled_from(["\r\n", "\n"]))
+ folded = folds.reduce(text) { |line, at| line.dup.insert(at, "#{eol}#{tc.draw(sampled_from([" ", "\t"]))}") }
+ line = VCARD.new("#{folded}#{eol}").lines.fetch(0)
+ asked = tc.draw(one_of(just(name), sampled_from(%w[NOTE ADR BDAY TEL]), token))
+
+ assert_equal name.casecmp?(asked), line.names?(asked)
+ assert_equal name, line.property&.name
+ end
+ end
+
# A broken assumption and an ordinary unreadable line are both
# errors on a line, and telling them apart belongs here rather than
# in whoever reports it: news about this server and ordinary bad