vcard: a card with a NUL in it is not text
Sequel writes a card into its INSERT as a literal and SQLite's
tokenizer stops at a NUL, so a card holding one failed the write with
a Sequel::DatabaseError whose message carried the card's bytes to
Sentry. A NUL is not in RFC 2425's VALUE-CHAR, so VCard.text? now asks
for UTF-8 with no NUL: a PUT holding one is refused as
valid-address-data, an upload as not text, and VCard.new raises
without naming the bytes for anything else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit aa7b9c3e6e97d8acddcca853aedb1345a3d7a8fc
author Claude <noreply@anthropic.com>
date
parent b91ec8af
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index d8df766..9a3f63c 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -108,19 +108,34 @@ module ProTacts
       "#{prefix}#{property.name}#{parameters.join}:"
     end
 
-    # A card is made of UTF-8 text, and refuses to be made of anything
-    # else: bytes that are not valid UTF-8 raise here, at the boundary,
-    # rather than leaking an ArgumentError out of whatever regex first
-    # trips over them. Raising rather than answering, because no caller
-    # is meant to be asking — a card's bytes come from a column that can
-    # hold nothing else or from a body already decoded, and whether some
-    # bytes are text at all is that decode's question, settled before
-    # anything here sees them (ProTacts::Web's write_card). This is the
-    # assertion under those callers, not a check any of them makes; the
-    # store's bind holds the same line a third time below the card.
+    # Whether bytes are text a card can be made of: valid UTF-8, and no
+    # NUL. RFC 2425 section 5.8.3's VALUE-CHAR has no controls but
+    # whitespace in it, and the NUL is the one of them nothing here can
+    # carry: Sequel writes a card into its INSERT as a literal, SQLite's
+    # tokenizer stops at the NUL, and the error that raises carries the
+    # card's bytes in its message — to Sentry, which card content must
+    # never reach. The question the decodes ask (Web#write_card, the
+    # import's upload) before anything here sees the bytes.
+    #: (String bytes) -> bool
+    def self.text?(bytes)
+      bytes.valid_encoding? && !bytes.include?("\0")
+    end
+
+    # A card is made of text, and refuses to be made of anything else:
+    # bytes that are not (.text?) raise here, at the boundary, rather
+    # than leaking an ArgumentError out of whatever regex first trips
+    # over them or SQLite's out of the insert. Raising rather than
+    # answering, because no caller is meant to be asking — a card's
+    # bytes come from a column that can hold nothing else or from a body
+    # already decoded, and whether some bytes are text at all is that
+    # decode's question, settled before anything here sees them
+    # (ProTacts::Web's write_card). This is the assertion under those
+    # callers, not a check any of them makes; the store's bind holds the
+    # encoding a third time below the card. The message names neither
+    # the bytes nor where they went wrong, for Sentry's sake.
     #: (String bytes, ?lines: Array[Parser::Line]?) -> void
     def initialize(bytes, lines: nil)
-      raise ArgumentError, "not valid UTF-8: a card is text" unless bytes.valid_encoding?
+      raise ArgumentError, "not text: a card is UTF-8 with no NUL" unless VCard.text?(bytes)
 
       @bytes = bytes
       @lines = lines if lines
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 161c11b..9775637 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -399,7 +399,8 @@ module ProTacts
       # label is checked honest in the same breath, because a
       # force_encoding nobody validates is a lie every later reader
       # inherits. That the bytes are not the UTF-8 the media type
-      # promised is a fact about this body, not about any card, which is
+      # promised, or carry the NUL no card's text can (VCard.text?), is
+      # a fact about this body, not about any card, which is
       # why it is settled here and VCard is never handed the question
       # (CARDDAV:valid-address-data, RFC 6352 section 6.3.2.1). Left
       # unsettled it would surface as a 500 out of SQLite on the insert,
@@ -407,7 +408,7 @@ module ProTacts
       # Paths need no counterpart: Puma hands PATH_INFO over still
       # percent-encoded, so an id off the wire is ASCII.
       bytes = request.body.read.force_encoding(Encoding::UTF_8)
-      return precondition(:valid_address_data) unless bytes.valid_encoding?
+      return precondition(:valid_address_data) unless VCard.text?(bytes)
 
       # CARDDAV:valid-address-data again, on the card this time: the
       # envelope RFC 2426 section 4 requires. That is the whole test. A
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index d417988..9d8941d 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -95,7 +95,7 @@ module ProTacts
       # for the one other body this app reads: a multipart part arrives
       # as binary.
       bytes = upload.fetch(:tempfile).read.force_encoding(Encoding::UTF_8)
-      return import_screen(notice: "#{name} is not UTF-8 text.") unless bytes.valid_encoding?
+      return import_screen(notice: "#{name} is not UTF-8 text.") unless VCard.text?(bytes)
 
       begin
         # A Monica export is read as the cards it composes, and from
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 187ab0a..4ebe1cd 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -1165,6 +1165,17 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
+  # A NUL is not text either (VCard.text?), and would leave nothing the
+  # store could write.
+  def test_a_file_holding_a_nul_stages_nothing
+    with_contacts({}) do |store|
+      upload(JANE.sub("Jane", "Ja\0ne"))
+
+      assert_includes last_response.body, "contacts.vcf is not UTF-8 text."
+      assert_empty store.changes
+    end
+  end
+
   # A Monica export is read as the cards it composes, and is a walk like
   # any other from there (Import::Monica).
   def test_a_monica_export_opens_the_walk_on_its_contacts
diff --git a/test/pro_tacts/import/test_match.rb b/test/pro_tacts/import/test_match.rb
index 1860532..7d052e3 100644
--- a/test/pro_tacts/import/test_match.rb
+++ b/test/pro_tacts/import/test_match.rb
@@ -164,9 +164,9 @@ class ImportMatchTest < Minitest::Test
 
   # Emails as a card might carry them: real addresses, ones sharing a
   # local part or a domain, and the malformed — no @, nothing before it.
-  def email = one_of(emails, sampled_from(["ada@example.com", "ADA@example.com", "@example.com", "ada", ""]), text(max_size: 8))
+  def email = one_of(emails, sampled_from(["ada@example.com", "ADA@example.com", "@example.com", "ada", ""]), text(max_size: 8, min_codepoint: 1))
 
-  def phone = one_of(sampled_from(["+1 555 0100", "(555) 0100", "n/a", ""]), text(max_size: 8))
+  def phone = one_of(sampled_from(["+1 555 0100", "(555) 0100", "n/a", ""]), text(max_size: 8, min_codepoint: 1))
 
   # A likeness is a share, between 0 for nothing alike and 1 for a value
   # both share, whatever the cards hold — and so the ranking over them
@@ -174,8 +174,8 @@ class ImportMatchTest < Minitest::Test
   def test_every_likeness_is_a_share
     Hegel.test(test_cases: 1_000) do |tc|
       lines = ->(name, values) { values.map { "#{name}:#{ProTacts::VCard.escape(it)}" } }
-      arriving = contact("ada", tc.draw(text(max_size: 8)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
-      stored = contact("bob", tc.draw(text(max_size: 8)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
+      arriving = contact("ada", tc.draw(text(max_size: 8, min_codepoint: 1)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
+      stored = contact("bob", tc.draw(text(max_size: 8, min_codepoint: 1)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
 
       assert Match.likeness(arriving, stored).all? { (0.0..1.0).cover?(it) }, Match.likeness(arriving, stored).inspect
     end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index f635d82..de25279 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -362,7 +362,7 @@ class ImportVcfTest < Minitest::Test
   # 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))
+    tag = one_of(text(max_size: 10, min_codepoint: 1), 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(",")}"
diff --git a/test/pro_tacts/test_card_diff.rb b/test/pro_tacts/test_card_diff.rb
index a0cf39a..f1df81d 100644
--- a/test/pro_tacts/test_card_diff.rb
+++ b/test/pro_tacts/test_card_diff.rb
@@ -80,7 +80,7 @@ class CardDiffTest < Minitest::Test
   # carries some twice, beside lines drawn fresh.
   COMMON = ["FN:Ada", "NOTE:one two", "TEL:+1-555-0100", "item1.EMAIL:ada@example.com"].freeze #: Array[String]
 
-  def content = one_of(sampled_from(COMMON), from_regex('[A-Z]{1,4}:[^\r\n]{0,10}', fullmatch: true))
+  def content = one_of(sampled_from(COMMON), from_regex('[A-Z]{1,4}:[^\x00\r\n]{0,10}', fullmatch: true))
 
   def contents = arrays(content, max_size: 8)
 
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 38da5ec..c39d604 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -2526,10 +2526,8 @@ class StoreTest < Minitest::Test
 
     # A card a write might carry: any name, and a TEL or two that may
     # repeat, which a diff counts rather than sets. Any name but one
-    # holding a NUL, which no write survives today: Sequel writes the
-    # card into its INSERT as a literal, SQLite's tokenizer stops at the
-    # NUL, and the write raises with the card's bytes in its message.
-    # That is the store's open question, not this machine's.
+    # holding a NUL, which is not text a card can be made of
+    # (VCard.text?).
     def card(tc, id)
       name = ProTacts::VCard.escape(tc.draw(text(max_size: 10, min_codepoint: 1)))
       phones = tc.draw(arrays(sampled_from(["TEL:+1 555 0100\r\n", "TEL:+1 555 0199\r\n"]), max_size: 3))
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 256263e..f8573a3 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -195,7 +195,30 @@ class VCardTest < Minitest::Test
 
     error = assert_raises(ArgumentError) { ProTacts::VCard.new(invalid) }
 
-    assert_match "not valid UTF-8", error.message
+    assert_match "not text", error.message
+  end
+
+  # A NUL is no character a card is made of (RFC 2425's VALUE-CHAR),
+  # and the one the store cannot carry: wherever it falls, the bytes
+  # are not text, and the refusal's message carries none of them, so a
+  # report of it cannot leak the card into Sentry.
+  def test_a_nul_anywhere_makes_bytes_not_a_card
+    Hegel.test(test_cases: 1_000) do |tc|
+      text = tc.draw(text(max_size: 40, min_codepoint: 1))
+      at = tc.draw(integers(min_value: 0, max_value: text.length))
+      bytes = text.dup.insert(at, "\0")
+
+      error = assert_raises(ArgumentError) { ProTacts::VCard.new(bytes) }
+      assert_equal "not text: a card is UTF-8 with no NUL", error.message
+    end
+  end
+
+  # And everything else valid UTF-8 is text, controls and all: the
+  # refusal is the NUL's alone.
+  def test_utf_8_without_a_nul_is_text
+    Hegel.test(test_cases: 1_000) do |tc|
+      assert ProTacts::VCard.text?(tc.draw(text(max_size: 40, min_codepoint: 1)))
+    end
   end
 
   ## The envelope
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 1a1f60b..f7c1b09 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -534,6 +534,18 @@ class WebTest < Minitest::Test
     end
   end
 
+  # A NUL is not text a card is made of (VCard.text?), and refused
+  # here rather than at the insert that cannot hold it.
+  def test_put_of_a_card_holding_a_nul_names_its_precondition
+    with_contacts({}) do |store|
+      put_request "new", card("new", "Ne\0w"), "CONTENT_TYPE" => VCARD
+
+      assert_equal 412, last_response.status
+      assert_includes last_response.body, "<card:valid-address-data/>"
+      assert_empty store.changes
+    end
+  end
+
   def test_put_of_something_that_is_not_a_card_names_its_precondition
     with_contacts({}) do
       put_request "new", "not a vCard at all", "CONTENT_TYPE" => VCARD
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index 46c084e..06ef6d9 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -53,7 +53,7 @@ class VCardLinesTest < Minitest::Test
 
     error = assert_raises(ArgumentError) { VCARD.new(invalid) }
 
-    assert_match "not valid UTF-8", error.message
+    assert_match "not text", error.message
   end
 
   # The envelope RFC 2426 section 4 requires, the one judgment a PUT
@@ -343,7 +343,7 @@ class VCardLinesTest < Minitest::Test
   def content_line
     composite { |dtc|
       terminator = dtc.draw(sampled_from(["\r\n", "\n"]))
-      value = dtc.draw(from_regex('[^\r\n]{0,12}', fullmatch: true))
+      value = dtc.draw(from_regex('[^\x00\r\n]{0,12}', fullmatch: true))
       fold = dtc.draw(integers(min_value: 0, max_value: value.length))
       folded = fold.zero? ? value : value.dup.insert(fold, "#{terminator} ")
       dtc.draw(booleans(p: 0.1)) ? "garbage#{terminator}" : "#{dtc.draw(sampled_from(NAMES))}:#{folded}#{terminator}"
@@ -352,7 +352,7 @@ class VCardLinesTest < Minitest::Test
 
   # Lines an edit hands in, bare or terminated, each naming `name`.
   def edit_lines(name)
-    line = tuples(from_regex('[^\r\n]{0,12}', fullmatch: true), sampled_from(["", "\r\n", "\n"]))
+    line = tuples(from_regex('[^\x00\r\n]{0,12}', fullmatch: true), sampled_from(["", "\r\n", "\n"]))
     arrays(line, max_size: 3).map { |lines| lines.map { |value, terminator| "#{name}:#{value}#{terminator}" } }
   end