Inline the UTF-8 relabel at the body read
The helper had two call sites and one was guarding a case this stack
never produces: Puma sets PATH_INFO verbatim from REQUEST_PATH, still
percent-encoded, so a path-derived id is always ASCII. The body — forced
to binary by Rack::RewindableInput — is the one crossing, and one call
site is not a method.
Assisted-by: GLM-5.3 via pi
diff --git a/AGENTS.md b/AGENTS.md
index 2646d17..a95c125 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -113,12 +113,15 @@ not a fixture; edit a `.vcf` to change what the replay serves.
Never edit a migration that has run anywhere; add the next one.
- Those tables are `STRICT`, so every string column needs `text: true`:
Sequel's plain `String` is a varchar, which STRICT refuses. Strings
- reach the store as UTF-8 by contract — `Web#utf8` relabels Rack's
- binary (the request body, path segments carrying non-ASCII) where the
- wire meets the route — and the adapter enforces it: the sqlite3 gem
- encodes every bound value to UTF-8, so binary-flagged bytes above 7
- bits raise at the bind, while bytes that are not UTF-8 at all are
- refused by SQLite on the insert.
+ reach the store as UTF-8 by contract, and the adapter enforces it: the
+ sqlite3 gem encodes every bound value to UTF-8, so binary-flagged
+ bytes above 7 bits raise at the bind, while bytes that are not UTF-8
+ at all are refused by SQLite on the insert. The one binary input is
+ the request body — Rack requires input in ASCII-8BIT and
+ Rack::RewindableInput enforces it — relabelled to UTF-8 where it is
+ read, in `write_card`. Path-derived ids need no relabel: Puma hands
+ PATH_INFO over still percent-encoded (set verbatim from
+ REQUEST_PATH), so an id off the wire is ASCII.
- The app is handed its store rather than reaching for one:
`config.ru` builds it and sets `ProTacts::Web.store`, and a test does
the same with a throwaway. There is no global `ProTacts.store`, so
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 7315b80..8b7366c 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -157,8 +157,8 @@ module ProTacts
# to it: the sqlite3 gem encodes every bound value to UTF-8, so a
# binary-flagged byte above 7 bits raises at the bind, and bytes
# that are not UTF-8 at all stop at the insert, which SQLite refuses
- # to store as text. Rack's binary is relabelled before this —
- # Web#utf8, where the wire meets the route.
+ # to store as text. The body — the one binary input — is relabelled
+ # where it is read, in write_card.
#: (String id, String vcard) -> Contact
def put(id, vcard)
contact = Contact.for(id:, vcard:)
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 5d6bc0e..dd93a44 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -317,7 +317,7 @@ module ProTacts
# Read on its own rather than through the collection: serving
# one href has no reason to load every other contact first.
r.get String do |filename|
- contact = store.contact(utf8(filename.delete_suffix(".vcf")))
+ contact = store.contact(filename.delete_suffix(".vcf"))
# No match falls through to the empty-body 404 that the
# not_found handler fills in.
@@ -384,7 +384,13 @@ module ProTacts
# CARDDAV:valid-address-data even when its If-Match is stale too.
#: (String id) -> String
def write_card(id)
- vcard = utf8(request.body.read)
+ # 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
@@ -445,18 +451,6 @@ module ProTacts
nil
end
- # Where the wire's bytes become the app's text. Rack hands the
- # request body in as ASCII-8BIT, and path segments carrying non-ASCII
- # likewise (its spec's rule for CGI values), while everything past
- # the route — the store, the parser, the responses — speaks UTF-8.
- # Relabelling is not converting: the bytes are untouched, and a body
- # that is not UTF-8 at all is caught by the charset check in
- # write_card rather than here.
- #: (String wire) -> String
- def utf8(wire)
- wire.encoding == Encoding::BINARY ? wire.dup.force_encoding(Encoding::UTF_8) : wire
- end
-
# A 412 whose body names the CardDAV precondition that failed, in
# the DAV:error form RFC 4918 section 16 defines; the element names
# are RFC 6352 section 6.3.2.1's, qualified by the card: namespace