web: decode the body and check the decode together
The media-type check moves above the read to keep RFC 7232's
precondition order, since it asks nothing of the body.

Assisted-by: Claude Opus 5 via Claude Code
change lozlyzunzwmryuwokotpotwpszqoulys
commit e57c177fa0e12094ee454a3ab3e6c94da173a549
author Alpha Chen <alpha@kejadlen.dev>
date
parent tswokztt
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index c1641d7..a2dd24a 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -80,8 +80,13 @@ module ProTacts
     # 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. A PUT gates this with its own 412 first, and the
-    # store's bind holds the same line again below the card.
+    # 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.
     #: (String bytes) -> void
     def initialize(bytes)
       raise ArgumentError, "not valid UTF-8: a card is text" unless bytes.valid_encoding?
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 496d650..a6f8f51 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -432,32 +432,35 @@ module ProTacts
     # CARDDAV:valid-address-data even when its If-Match is stale too.
     #: (String id) -> String?
     def write_card(id)
-      # The body is the one binary input: Rack requires input in
-      # ASCII-8BIT and Rack::RewindableInput enforces it again. Relabel
-      # rather than convert — the bytes are untouched, and the charset
-      # check below is what judges them. Paths need no counterpart:
-      # Puma hands PATH_INFO over still percent-encoded, so an id off
-      # the wire is ASCII.
-      vcard = request.body.read.force_encoding(Encoding::UTF_8)
-
       # CARDDAV:supported-address-data (RFC 6352 section 6.3.2.1): what
       # arrived must be a vCard, and text/vcard is the one media type
-      # this server stores.
+      # this server stores. Asked before the body is read because it is
+      # a question about the request, not about what the request
+      # carried.
       return precondition("supported-address-data") unless request.media_type == "text/vcard"
 
-      # CARDDAV:valid-address-data: bytes that are the UTF-8 the media
-      # type declares, carrying the envelope RFC 2426 section 4
-      # requires. Invalid UTF-8 would otherwise surface as a 500 out of
-      # SQLite on the insert, past the point where the client could be
-      # told what was wrong with the body.
-      #
-      # The envelope is the whole test. A line inside it this parser
-      # cannot read is not grounds for refusing the card: RFC 6352
-      # section 6.3.2.2 has the server keep what it does not
-      # understand, and it is kept — the stored bytes are what goes
-      # back out.
+      # Decoding the body, both halves of it: Rack requires input in
+      # ASCII-8BIT and Rack::RewindableInput enforces it again, so the
+      # bytes are relabelled rather than converted — untouched — and the
+      # 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
+      # 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,
+      # past the point where the client could be told what was wrong.
+      # Paths need no counterpart: Puma hands PATH_INFO over still
+      # percent-encoded, so an id off the wire is ASCII.
+      vcard = request.body.read.force_encoding(Encoding::UTF_8)
       return precondition("valid-address-data") unless vcard.valid_encoding?
 
+      # CARDDAV:valid-address-data again, on the card this time: the
+      # envelope RFC 2426 section 4 requires. That is the whole test. A
+      # line inside it this parser cannot read is not grounds for
+      # refusing the card: RFC 6352 section 6.3.2.2 has the server keep
+      # what it does not understand, and it is kept — the stored bytes
+      # are what goes back out.
       card = VCard.new(vcard)
       return precondition("valid-address-data") unless card.card?