import: a card begun inside another refuses the file
Vcf.cards read a BEGIN inside an open card as one more line of it, so
a card with no END folded the next person into it. A nested BEGIN now
refuses the file, the way a line outside a card does.
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 db1faf4..a293cbd 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -70,7 +70,11 @@ module ProTacts
# bytes it arrived in. A card is what lies from BEGIN to END, and
# the lines between cards are blank or the file is not a set of
# vCards — refused whole rather than partly imported: a source
- # that will not read brings in nothing.
+ # that will not read brings in nothing. A BEGIN inside a card is
+ # refused the same way: vCard 3.0 nests a card only escaped into
+ # one AGENT value (RFC 2426 section 3.5.4), so a second BEGIN is
+ # a card that never ended, and reading on would fold two people
+ # into one.
#: (String bytes) -> Array[VCard]
def self.cards(bytes)
cards = [] #: Array[VCard]
@@ -84,6 +88,8 @@ module ProTacts
open = [line]
else
+ raise Invalid, "a card begins inside another: #{summary(line)}" if name&.casecmp?("BEGIN")
+
open << line
next unless name&.casecmp?("END")
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index 1bdc33d..107146a 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -339,6 +339,21 @@ class ImportVcfTest < Minitest::Test
end
end
+ # A BEGIN inside a card, anywhere between its BEGIN and END, refuses
+ # the file: that card never ended, and reading on would fold it into
+ # the next.
+ def test_a_card_begun_inside_another_refuses_the_file
+ Hegel.test(test_cases: 1_000) do |tc|
+ cards = tc.draw(arrays(card_text, min_size: 1, max_size: 3))
+ which = tc.draw(integers(min_value: 0, max_value: cards.length - 1))
+ lines = cards.fetch(which).lines
+ at = tc.draw(integers(min_value: 1, max_value: lines.length - 1))
+ cards[which] = lines.insert(at, tc.draw(sampled_from(["BEGIN:VCARD\r\n", "begin:vcard\r\n"]))).join
+
+ assert_raises(Vcf::Invalid) { Vcf.cards(cards.join) }
+ end
+ end
+
# The groups a reading's card keeps a label in, and the groups it
# keeps anything else in — a line that will not read included, its
# group read off its bytes.