vcard, import: split after an escaped backslash, drop a lone label
Two bugs Hegel properties found. A ";" or "," after an escaped
backslash ("C:\;John") was read as escaped, so a structured value
(N, ADR, ORG) or a CATEGORIES list whose component ended in a
backslash merged with the next one. The split now counts the run of
backslashes before a separator in pairs. And an X-ABLabel alone in its
group, with no line for it to name, came in with an import, though
Import::Vcf's comment has a group holding only a label stay behind.
The properties: escaped components split back as written, a rendered
header reads back as its property, CATEGORIES reads back the tags
written, a file reads as the cards it was made of, a stray line
refuses the file, and a label goes wherever its row goes.
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/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index 02d73e6..e524c71 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -156,7 +156,7 @@ module ProTacts
#: (VCard card) -> Array[String]
def self.categories(card)
card.properties.select { it.name.casecmp?(CATEGORIES) }.flat_map { |property|
- property.value.split(/(?<!\\),/).map { VCard.unescape(it).strip }
+ property.value.split(VCard::VALUE_SEPARATOR).map { VCard.unescape(it).strip }
}.reject(&:empty?).uniq
end
@@ -194,9 +194,12 @@ module ProTacts
next if property.nil?
group = property.group
- next if group.nil? || property.name.casecmp?(LABEL)
+ next if group.nil?
- anchored[group] = anchored.fetch(group, false) || known?(property.name)
+ # A label is in its group without anchoring it, so a group
+ # holding nothing else is one with nothing but a label in it.
+ anchors = !property.name.casecmp?(LABEL) && known?(property.name)
+ anchored[group] = anchored.fetch(group, false) || anchors
end
anchored.reject { |_group, kept| kept }.keys
end
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index d8ef090..a02aaa8 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -24,6 +24,13 @@ module ProTacts
# it without splitting a card's every line out to look.
END_LINE = /^END:VCARD/i #: Regexp
+ # A ";" after an even run of backslashes (#split_raw_components),
+ # and a "," after one: the separator of a list of text values,
+ # CATEGORIES's tags (RFC 2426 section 3.6.1), escaped by the same
+ # backslash for the same reason.
+ COMPONENT_SEPARATOR = /(?<!\\)(?:\\\\)*\K;/ #: Regexp
+ VALUE_SEPARATOR = /(?<!\\)(?:\\\\)*\K,/ #: Regexp
+
# Text values escape backslash, the component separator, and the
# sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
# normalized to the `\n` escape because a raw line break would end
@@ -60,9 +67,15 @@ module ProTacts
# byte for byte, where unescape-then-re-escape is not byte-stable
# (#unescape leaves an unrecognized escape like "\x" alone, and
# #escape would double its backslash).
+ #
+ # A separator is escaped by the backslash before it only when that
+ # backslash is not itself the second half of a "\\": a component
+ # ending in an escaped backslash ends at the ";" after it. So the
+ # split counts the run of backslashes in pairs, and \K leaves the
+ # pairs on the component they close.
#: (String value) -> Array[String]
def self.split_raw_components(value)
- value.split(/(?<!\\);/, -1)
+ value.split(COMPONENT_SEPARATOR, -1)
end
# split_raw_components with each component unescaped — the
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index 714958c..f635d82 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -1,11 +1,15 @@
require_relative "../../test_helper"
+require "hegel"
+
require "pro_tacts/import/vcf"
# Reading an uploaded .vcf: the split into cards, and the survey of
# what this address book does not show
# (docs/plans/2026-09-21-import-a-vcf.md).
class ImportVcfTest < Minitest::Test
+ include Hegel::Syntax::Methods
+
Vcf = ProTacts::Import::Vcf
JANE = <<~CARD.gsub("\n", "\r\n")
@@ -205,6 +209,15 @@ class ImportVcfTest < Minitest::Test
assert_includes reading.dropped.map { Vcf.summary(it) }, "item2.X-ABLabel:_$!<Spouse>!$_"
end
+ # A label alone in its group names nothing at all, so it stays
+ # behind like one whose line is leaving: the case the property below
+ # shrank to.
+ def test_a_label_with_no_line_in_its_group_stays_behind
+ reading = Vcf.read(Vcf.cards(JANE.sub("END:VCARD", "item9.X-ABLabel:orphan\r\nEND:VCARD")).fetch(0))
+
+ refute_includes reading.card.to_s, "orphan"
+ end
+
# There is no property name to have shown on a screen and nothing
# anyone could have decided about it, so it rides along rather than
# being thrown away unnamed.
@@ -270,4 +283,92 @@ class ImportVcfTest < Minitest::Test
assert_empty Vcf.unknown([card])
end
+ # A tag ending in a backslash escapes it, and the comma after that
+ # escaped backslash still separates.
+ def test_a_tag_ending_in_a_backslash_ends_at_the_comma
+ card = Vcf.cards("BEGIN:VCARD\r\nVERSION:3.0\r\nCATEGORIES:C:\\\\,school\r\nEND:VCARD\r\n").fetch(0)
+
+ assert_equal ["C:\\", "school"], Vcf.categories(card)
+ end
+
+ ## Property tests
+
+ # Lines from an Apple export's vocabulary: rows this book reads and
+ # rows it does not, labels hung off both by group, a label whose
+ # group holds nothing else, and the noise and tags read apart.
+ LINES = [
+ "N:Booles;Jane;;;", "FN:Jane Booles", "TEL:+1 555 0100", "item1.TEL:+1 555 0101",
+ "item1.X-ABLabel:_$!<Mobile>!$_", "item2.X-ABRELATEDNAMES:Sam", "item2.X-ABLabel:_$!<Spouse>!$_",
+ "item3.X-ABLabel:orphan", "item4.ADR:;;1 Main St;;;;", "item4.X-ABRELATEDNAMES:Pat",
+ "item4.X-ABLabel:_$!<Home>!$_", "X-SOCIALPROFILE:x", "PRODID:-//Apple", "CATEGORIES:Family",
+ ].freeze #: Array[String]
+
+ def card_text
+ arrays(sampled_from(LINES), max_size: 8).map { |lines|
+ ["BEGIN:VCARD", "VERSION:3.0", *lines, "END:VCARD"].map { "#{it}\r\n" }.join
+ }
+ end
+
+ # A file is the cards it holds, one after another, maybe with blank
+ # lines between them, and reads as those cards byte for byte.
+ def test_a_file_reads_as_the_cards_it_was_made_of
+ Hegel.test(test_cases: 1_000) do |tc|
+ cards = tc.draw(arrays(card_text, min_size: 1, max_size: 4))
+ file = cards.map { it + tc.draw(sampled_from(["", "\r\n", "\n", "\r\n\r\n"])) }.join
+
+ assert_equal cards, Vcf.cards(file).map(&:to_s)
+ end
+ end
+
+ # A line anywhere outside a card, between two or after the last,
+ # refuses the whole file rather than bringing in the cards around it.
+ def test_a_line_outside_a_card_refuses_the_file
+ Hegel.test(test_cases: 1_000) do |tc|
+ cards = tc.draw(arrays(card_text, min_size: 1, max_size: 4))
+ stray = tc.draw(one_of(sampled_from(LINES), from_regex("[A-Za-z][^\r\n]{0,10}", fullmatch: true)))
+ tc.assume(!stray.match?(/\ABEGIN/i))
+ at = tc.draw(integers(min_value: 1, max_value: cards.length))
+
+ assert_raises(Vcf::Invalid) { Vcf.cards(cards.insert(at, "#{stray}\r\n").join) }
+ end
+ end
+
+ # The groups a reading's card keeps a label in, and the groups it
+ # keeps anything else in.
+ def labeled_groups(card) = card.properties.select { it.name.casecmp?(Vcf::LABEL) }.map(&:group).uniq
+
+ def anchored_groups(card) = card.properties.reject { it.name.casecmp?(Vcf::LABEL) }.filter_map(&:group).uniq
+
+ # A label names the line beside it, so none comes in without one.
+ def test_no_label_comes_in_without_a_line_to_name
+ Hegel.test(test_cases: 1_000) do |tc|
+ card = Vcf.read(Vcf.cards(tc.draw(card_text)).fetch(0)).card
+
+ assert_empty labeled_groups(card) - anchored_groups(card)
+ end
+ end
+
+ # And the other half: a row coming in brings its label.
+ def test_a_row_coming_in_brings_its_label
+ Hegel.test(test_cases: 1_000) do |tc|
+ original = Vcf.cards(tc.draw(card_text)).fetch(0)
+ card = Vcf.read(original).card
+
+ assert_equal labeled_groups(original) & anchored_groups(card), labeled_groups(card)
+ end
+ end
+
+ # Tags written into a CATEGORIES line, each escaped as RFC 2426
+ # section 2.4.2 has a text value escaped, read back as the tags they
+ # were — blank ones and repeats aside, which name no group.
+ def test_categories_read_back_as_the_tags_written
+ tag = one_of(text(max_size: 10), arrays(sampled_from([",", "\\", ";", " ", "a"]), max_size: 5).map(&:join))
+ Hegel.test(test_cases: 1_000) do |tc|
+ tags = tc.draw(arrays(tag, min_size: 1, max_size: 5))
+ line = "CATEGORIES:#{tags.map { ProTacts::VCard.escape(it) }.join(",")}"
+ card = Vcf.cards("BEGIN:VCARD\r\nVERSION:3.0\r\n#{line}\r\nEND:VCARD\r\n").fetch(0)
+
+ assert_equal tags.map { it.gsub(/\r\n|\r/, "\n").strip }.reject(&:empty?).uniq, Vcf.categories(card)
+ end
+ end
end
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 696089a..28cb73d 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -62,6 +62,13 @@ class VCardTest < Minitest::Test
assert_equal ["Smith; Jr.", "John"], ProTacts::VCard.split_components("Smith\\; Jr.;John")
end
+ # The backslash before this ";" is the second half of an escaped
+ # backslash, so the separator is a separator: the case the property
+ # below shrank to.
+ def test_split_components_ends_a_component_at_an_escaped_backslash
+ assert_equal ["C:\\", "John"], ProTacts::VCard.split_components("C:\\\\;John")
+ end
+
# The third component is the case the byte-stability argument on
# VCard.split_raw_components turns on: an escape neither half of the
# pair recognizes, which a round trip through them would corrupt.
@@ -72,6 +79,41 @@ class VCardTest < Minitest::Test
ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb").join(";")
end
+ # Text heavy in the characters the escapes give meaning to, which a
+ # draw from all of Unicode reaches too rarely to land beside one
+ # another: a separator after a backslash, a backslash at a
+ # component's end, an escape neither half recognizes.
+ ESCAPEY = %W[; , \\ \\; \\\\ \\n \\x \n \r\n a].freeze #: Array[String]
+
+ def escapey_text = one_of(text(max_size: 20), arrays(sampled_from(ESCAPEY), max_size: 8).map(&:join))
+
+ # The two halves of a structured value agree: components escaped and
+ # joined by the writer read back, through the reader's split, as the
+ # components they were — whatever text they hold, a trailing
+ # backslash included. One empty component is the empty value, the
+ # same bytes as none, and reads as none; every caller pads to the
+ # positions it splices (Admin::CardForm).
+ def test_escaped_components_split_back_as_they_were
+ Hegel.test(test_cases: 1_000) do |tc|
+ components = tc.draw(arrays(escapey_text, min_size: 1, max_size: 7))
+ value = components.map { ProTacts::VCard.escape(it) }.join(";")
+ expected = value.empty? ? [] : components.map { it.gsub(/\r\n|\r/, "\n") }
+
+ assert_equal expected, ProTacts::VCard.split_components(value)
+ end
+ end
+
+ # The byte-stability a splice relies on: any value, escapes the
+ # writer never makes included, joins back from its raw components as
+ # the bytes it was.
+ def test_raw_components_join_back_byte_for_byte
+ Hegel.test(test_cases: 1_000) do |tc|
+ value = tc.draw(escapey_text)
+
+ assert_equal value, ProTacts::VCard.split_raw_components(value).join(";")
+ end
+ end
+
# The writer's line header, for a value-only edit: group, name,
# and every parameter re-render as they read, so a save swaps the
# value and keeps what the form never modeled. The macOS triple
@@ -107,6 +149,27 @@ class VCardTest < Minitest::Test
assert_equal 'TEL;X-FOO="a:b,c":', ProTacts::VCard.header_of(quoted)
end
+ # Whatever a parse could have read, header_of renders a header that
+ # reads back as it: group, name, and every parameter, a value the
+ # grammar must quote included. A parameter value is anything but the
+ # controls (tab aside) and DQUOTE, which neither spelling can hold.
+ def test_a_rendered_header_reads_back_as_the_property
+ token = from_regex("[A-Za-z0-9-]{1,10}", fullmatch: true)
+ parameter_value = from_regex('[^\x00-\x08\x0a-\x1f\x7f"]{0,12}', fullmatch: true)
+ value = from_regex('[^\r\n]{0,40}', fullmatch: true)
+ Hegel.test(test_cases: 1_000) do |tc|
+ property = ProTacts::VCard::Parser::Property.new(
+ group: tc.draw(optional(token)),
+ name: tc.draw(token),
+ parameters: tc.draw(arrays(tuples(token, parameter_value), max_size: 4)),
+ value: tc.draw(value),
+ )
+ line = "#{ProTacts::VCard.header_of(property)}#{property.value}\r\n"
+
+ assert_equal [property], ProTacts::VCard::Parser.lines(line).map(&:property)
+ end
+ end
+
## The card's bytes
# Relabelling bytes is not converting them, so bytes that are not