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
change
commit 082b66d0f188f8a29e98d6aee4dda81dea9900d8
author Claude <noreply@anthropic.com>
date
parent 9ca30f36
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.