Accept PUT from CardDAV clients
The RFC's two no-uid-conflict clauses collapse into one rule here —
the card's UID must name the href it is PUT to — because the id is
the UID in this server.

Assisted-by: GLM-5.3 via pi
change oxwrqorlpunupupwlkrkuvyzrkwmuutq
commit 50c48f9c4923e952d329b5aa7f1c17bb7db47a7a
author Alpha Chen <alpha@kejadlen.dev>
date
parent smpqplvu
diff --git a/AGENTS.md b/AGENTS.md
index c576538..fc1c112 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
 # Project conventions
 
-A read-only CardDAV server for a family address book, in Ruby on Roda.
+A CardDAV server for a family address book, in Ruby on Roda.
 Contacts are vCard 3.0 documents in SQLite, served as the bytes that
 were stored.
 
diff --git a/README.md b/README.md
index 146a75f..4b24f40 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@ does work is under Status below.
 
 ## Status
 
-Read-only and serving real data. Contacts live in a SQLite database at
-`data/contacts.db` (override the root with `PRO_TACTS_DATA_DIR`, or the
+Serving real data, reads and writes both. Contacts live in a SQLite
+database at `data/contacts.db` (override the root with `PRO_TACTS_DATA_DIR`, or the
 database path alone with `PRO_TACTS_DATABASE`), one row per contact,
 holding the vCard itself:
 
@@ -48,7 +48,9 @@ later work has a known-good baseline to change. See
 `docs/plans/2026-08-12-one-card-on-macos.md` for what that milestone
 established. A contact's etag hashes the card it serves and the
 collection tags hash the membership, so a change to a card reaches synced
-clients on their next poll.
+clients on their next poll. Writes arrive the same way: PUT stores the
+submitted card verbatim (RFC 6352 section 6.3.2), and the change log the
+sync tokens count on is written with it, in one transaction.
 
 Requests are authenticated by the `Tailscale-User-Login` header that
 `tailscale serve` injects, which it strips from incoming requests so a
@@ -90,6 +92,12 @@ carry:
 - `REPORT sync-collection`: `getetag` only — the client refetches changed
   cards through multiget or `GET` on its own.
 - `GET` a card: the vCard body plus an `ETag` header.
+- `PUT` a card: 201 for a create at an unmapped href, 204 for a replace,
+  both carrying the strong `ETag` — permitted because what is stored is
+  what was submitted (RFC 6352 section 6.3.2.3). A refused write is a
+  412 naming the precondition in a `DAV:error` body. This row follows
+  the RFC rather than client evidence: no live write had been answered
+  when it was written, so the first synced device to edit is its test.
 
 Three properties are load-bearing in non-obvious ways, documented in
 `docs/macos-contacts.md`: the collection's `resourcetype` (without
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 8bbf088..dd1494b 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -134,6 +134,20 @@ module ProTacts
       nil
     end
 
+    # The id of the card whose UID property holds this value, if one
+    # does — the read behind the no-uid-conflict precondition (RFC 6352
+    # section 6.3.2.1). It reads the index, so a card that failed to
+    # parse is invisible here; no card that arrived by PUT can be in
+    # that state, because PUT rejects what will not parse before
+    # storing it. `sole` for the same reason `contact` uses it: two
+    # cards sharing a UID is a corruption to raise on, not a choice.
+    #: (String uid) -> String?
+    def card_id_with_uid(uid)
+      card_properties.where(name: "UID", value: text(uid)).sole.fetch(:card_id).to_s
+    rescue Sequel::NoMatchingRow
+      nil
+    end
+
     # Stores a card and everything that has to move with it: the
     # change-log entry a client's sync token counts on, carrying the etag
     # this card hashed to now, and the index rows read off the card. One
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index fe132b8..a54fc34 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -49,6 +49,31 @@ module ProTacts
     # @rbs skip
     Property = Data.define(:group, :name, :parameters, :value)
 
+    # Whether parsed properties carry the envelope RFC 2426 section 4
+    # requires of a card: BEGIN:VCARD first, END:VCARD last, and a
+    # VERSION in between. Deciding that is deliberately not the
+    # parser's job — it reads lines without judging the card — so it
+    # lives here, where a caller that has to decide (a PUT) can ask.
+    # The VERSION's value is not judged: any version is stored verbatim,
+    # and this only decides whether there is a card at all.
+    #: (Array[Property] properties) -> bool
+    def self.card?(properties)
+      return false if properties.empty?
+
+      first, last = properties.first, properties.last
+      first.name.casecmp?("BEGIN") && first.value.casecmp?("VCARD") &&
+        last.name.casecmp?("END") && last.value.casecmp?("VCARD") &&
+        properties.any? { it.name.casecmp?("VERSION") }
+    end
+
+    # The value of the card's UID property, if it carries one. Names
+    # compare without case, as the index's NOCASE collation already
+    # assumes for them.
+    #: (Array[Property] properties) -> String?
+    def self.uid(properties)
+      properties.find { it.name.casecmp?("UID") }&.value
+    end
+
     # Text values escape backslash, the component separator, and the
     # sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
     # normalized to the `\n` escape because a raw line break would end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 6d9633a..80da52d 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -130,7 +130,7 @@ module ProTacts
         # support (RFC 6352 section 6.1); the header is RFC 4918 section 10.1.
         r.options do
           response["DAV"] = "addressbook"
-          response["Allow"] = "OPTIONS, PROPFIND, REPORT"
+          response["Allow"] = "OPTIONS, PROPFIND, REPORT, PUT"
           ""
         end
 
@@ -191,14 +191,16 @@ module ProTacts
               # is not standardized — an Apple CalendarServer extension in the
               # calendarserver.org namespace, kept because macOS polls it.
               #
-              # The privileges are advertised before the methods granting them
-              # exist. macOS Contacts asks for this property on every poll and
-              # attempts no write without it, so claiming the privileges is
-              # what makes the client send a PUT this server can capture in
-              # log/unhandled — the point of the experiment, not an oversight.
-              # DAV:write covers PUT and PROPPATCH (RFC 3744 section 3.2),
-              # DAV:bind adding a member to the collection (section 3.9), and
-              # DAV:unbind removing one (section 3.10).
+              # The privileges are advertised ahead of the methods
+              # granting some of them. macOS Contacts asks for this
+              # property on every poll and attempts no write without it,
+              # so claiming them is what makes the client send writes at
+              # all — the PUT they prompted was what log/unhandled
+              # captured them for. DAV:write covers PUT and PROPPATCH
+              # (RFC 3744 section 3.2), and PUT is the one of the pair
+              # answered; DAV:bind is adding a member to the collection
+              # (section 3.9) and DAV:unbind removing one (section 3.10),
+              # neither of which has a route yet.
               collection_response = <<~XML
                 <d:response>
                   <d:href>/dav/addressbook/</d:href>
@@ -327,6 +329,20 @@ module ProTacts
               contact.vcard
             end
           end
+
+          # PUT to an unmapped URI creates the card there and PUT to a
+          # mapped one replaces it (RFC 6352 section 6.3.2). macOS
+          # creates with If-None-Match: * and a UUID it mints into both
+          # the URI and the card's UID, and updates with If-Match
+          # carrying the strong etag it was last served — see
+          # docs/macos-contacts.md, "What a write looks like on the wire".
+          r.put String do |filename|
+            id = filename.delete_suffix(".vcf")
+            # A last segment that is not this server's <id>.vcf shape
+            # cannot address a resource here, created or read — the
+            # same fall-through-to-404 the GET handler gives it.
+            write_card(id) if id.match?(Contact::ID_FORMAT)
+          end
         end
       end
     end
@@ -359,6 +375,131 @@ module ProTacts
       self.class.store
     end
 
+    # The whole of the PUT route: a private method because a Roda route
+    # block cannot return early, so each refusal is a value the block
+    # ends with rather than a branch it exits. The checks run in the
+    # order RFC 7232 section 5 sets for a request with preconditions —
+    # the request's own validity first, the conditionals on stored
+    # state after — so a card that cannot be stored hears
+    # CARDDAV:valid-address-data even when its If-Match is stale too.
+    #: (String id) -> String
+    def write_card(id)
+      vcard = request.body.read
+      # Rack reads a body in as ASCII-8BIT; relabel rather than convert,
+      # the way Store#text does, so the charset check and the insert see
+      # the same bytes.
+      vcard = vcard.dup.force_encoding(Encoding::UTF_8) if vcard.encoding == Encoding::BINARY
+
+      # 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.
+      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, parsing into a card with 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.
+      return precondition("valid-address-data") unless vcard.valid_encoding?
+
+      properties = parse_card(vcard)
+      return precondition("valid-address-data") unless properties && VCard.card?(properties)
+
+      # CARDDAV:no-uid-conflict: the submitted UID must not belong to a
+      # different resource, and a mapped URI must not be overwritten by
+      # a card carrying a different UID. Here the id is the card's UID
+      # (see Contact), so both clauses come down to: the card carries a
+      # UID naming the resource being written, and no other card claims
+      # it. The href in the body is the SHOULD that section attaches to
+      # the first clause — report where the UID already lives.
+      uid = VCard.uid(properties)
+      owner = uid && store.card_id_with_uid(uid)
+      return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
+
+      existing = store.contact(id)
+
+      # The lost-update conditionals, RFC 7232 sections 3.1 and 3.2.
+      # macOS sends If-Match on updates and If-None-Match: * on creates;
+      # a PUT carrying neither is unconditional and allowed to proceed.
+      if_match = request.env["HTTP_IF_MATCH"]
+      return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+
+      if_none_match = request.env["HTTP_IF_NONE_MATCH"]
+      return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
+
+      stored = store.put(id, vcard)
+      response.status = existing ? 204 : 201
+      # A strong ETag belongs on the answer because what was stored is
+      # the submitted bytes, octet for octet — the one case RFC 6352
+      # section 6.3.2.3 says a client may rely on the tag it gets back.
+      # Nothing subtracts from a card before storage yet; when a group
+      # contributes properties at write time, that PUT must stop
+      # sending one.
+      response["ETag"] = stored.etag
+      ""
+    end
+
+    # A parse that answers nil rather than raising when the bytes are
+    # not a card — the same tolerance Store#properties_of gives the
+    # index, at the point where a caller must decide.
+    #: (String vcard) -> Array[VCard::Property]?
+    def parse_card(vcard)
+      VCard::Parser.parse(vcard)
+    rescue VCard::ParseError
+      nil
+    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
+    # the body declares. no-uid-conflict carries the conflicting card's
+    # href when there is one.
+    #: (String element, ?String? conflict_id) -> String
+    def precondition(element, conflict_id = nil)
+      named = if conflict_id
+        "<card:#{element}><d:href>#{contact_href(conflict_id)}</d:href></card:#{element}>"
+      else
+        "<card:#{element}/>"
+      end
+      response.status = 412
+
+      <<~XML
+        <?xml version="1.0" encoding="UTF-8"?>
+        <d:error xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+          #{named}
+        </d:error>
+      XML
+    end
+
+    # A bare 412: these failures are HTTP's own conditionals (RFC 7232),
+    # not a WebDAV precondition with an element to name in a body.
+    #: () -> String
+    def plain_412
+      response.status = 412
+      ""
+    end
+
+    # RFC 7232 section 3.1: If-Match passes when the current etag is one
+    # of those listed, or, for `*`, when there is a current
+    # representation at all.
+    #: (String header, Contact? existing) -> bool
+    def if_match_satisfied?(header, existing)
+      return !existing.nil? if header.strip == "*"
+
+      !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
+    end
+
+    # RFC 7232 section 3.2: If-None-Match fails a non-GET request when
+    # the current etag is one of those listed, or, for `*`, whenever the
+    # resource exists. The create — If-None-Match: * against an unmapped
+    # URI — is the case CardDAV clients send (RFC 6352 section 6.3.2).
+    #: (String header, Contact? existing) -> bool
+    def if_none_match_failed?(header, existing)
+      return !existing.nil? if header.strip == "*"
+
+      !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
+    end
+
     #: (Array[String] responses) -> String
     def multistatus(responses)
       <<~XML
diff --git a/lib/roda/plugins/dav_verbs.rb b/lib/roda/plugins/dav_verbs.rb
index b9bef93..9bff638 100644
--- a/lib/roda/plugins/dav_verbs.rb
+++ b/lib/roda/plugins/dav_verbs.rb
@@ -5,12 +5,11 @@ class Roda
   module RodaPlugins
     # Adds routing methods for WebDAV/CardDAV HTTP verbs.
     #
-    # Only PROPFIND and REPORT are implemented since this is a read-only
-    # CardDAV server. PROPFIND handles discovery and property retrieval,
-    # REPORT handles addressbook-query and addressbook-multiget requests.
-    #
-    # Other WebDAV verbs (PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK)
-    # would be needed for write support.
+    # Only PROPFIND and REPORT are implemented here because they are the
+    # two with no routing method in Roda itself; PUT arrives through
+    # plugin :all_verbs. Other WebDAV verbs (PROPPATCH, MKCOL, COPY,
+    # MOVE, LOCK, UNLOCK) would each need a line here when one is
+    # answered.
     module DavVerbs
       module RequestMethods
         %w[propfind report].each do |verb|
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 9203d73..cf6737b 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -28,6 +28,12 @@ class Roda
     def on: (*untyped matchers) { (*untyped) -> untyped } -> void
     def get: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
     def options: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+    def put: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+
+    # The media type of the request body, without parameters — inherited
+    # from Rack::Request in the gem, restated here because the local
+    # RodaRequest does not.
+    def media_type: () -> String?
 
     # Halts the request, so nothing after it in a route block runs.
     def redirect: (String path, ?Integer status) -> bot
diff --git a/test/fixtures/macos-exchange/03-options-principal/response b/test/fixtures/macos-exchange/03-options-principal/response
index df20a27..6f2b25a 100644
--- a/test/fixtures/macos-exchange/03-options-principal/response
+++ b/test/fixtures/macos-exchange/03-options-principal/response
@@ -1,5 +1,5 @@
 200
 Content-Type: text/html
 DAV: addressbook
-Allow: OPTIONS, PROPFIND, REPORT
+Allow: OPTIONS, PROPFIND, REPORT, PUT
 
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index fb31300..e991873 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -48,6 +48,32 @@ class StoreTest < Minitest::Test
     with_store { assert_nil it.contact("nobody") }
   end
 
+  ## UIDs
+
+  # The read behind the no-uid-conflict precondition (RFC 6352 section
+  # 6.3.2.1): which card, if any, owns this UID.
+  def test_the_card_holding_a_uid_is_found_by_it
+    with_store({"aiden" => AIDEN}) do |store|
+      assert_equal "aiden", store.card_id_with_uid("aiden")
+    end
+  end
+
+  def test_a_uid_no_card_holds_finds_no_owner
+    with_store({"aiden" => AIDEN}) do |store|
+      assert_nil store.card_id_with_uid("znorth")
+    end
+  end
+
+  # The lookup runs through the index, whose name column ignores case,
+  # so a card that spelled the property lowercase is still found.
+  def test_a_lowercase_uid_property_is_found
+    lowercase = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nuid:aiden\r\nEND:VCARD\r\n"
+
+    with_store({"aiden" => lowercase}) do |store|
+      assert_equal "aiden", store.card_id_with_uid("aiden")
+    end
+  end
+
   def test_putting_the_same_id_replaces_the_card
     with_store({"aiden" => AIDEN}) do |store|
       updated = AIDEN.sub("Aiden", "Aiden Smith")
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index f762c54..dd1ff18 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -51,4 +51,43 @@ class VCardTest < Minitest::Test
       raise "unfold did not reverse fold" unless ProTacts::VCard.unfold(ProTacts::VCard.fold(line)) == line
     end
   end
+
+  ## The envelope
+
+  # What a PUT checks before storing: RFC 2426 section 4 has a card
+  # begin and end itself and declare a version, and lines that parse
+  # into properties are not yet a card.
+  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+  def test_properties_with_the_envelope_are_a_card
+    assert ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(CARD))
+  end
+
+  def test_no_properties_are_not_a_card
+    refute ProTacts::VCard.card?([])
+  end
+
+  def test_each_piece_of_the_envelope_is_required
+    no_begin = "VERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+    no_end = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\n"
+    no_version = "BEGIN:VCARD\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+    [no_begin, no_end, no_version].each do |card|
+      refute ProTacts::VCard.card?(ProTacts::VCard::Parser.parse(card)), card
+    end
+  end
+
+  def test_envelope_names_compare_without_case
+    lowercase = "begin:vcard\r\nversion:3.0\r\nfn:Aiden\r\nuid:aiden\r\nend:vcard\r\n"
+    properties = ProTacts::VCard::Parser.parse(lowercase)
+
+    assert ProTacts::VCard.card?(properties)
+    assert_equal "aiden", ProTacts::VCard.uid(properties)
+  end
+
+  def test_a_card_without_a_uid_has_none
+    card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
+
+    assert_nil ProTacts::VCard.uid(ProTacts::VCard::Parser.parse(card))
+  end
 end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index aefd62e..cdf570e 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -147,6 +147,235 @@ class WebTest < Minitest::Test
     assert_equal "Not Found", last_response.body
   end
 
+  ## PUT
+
+  # One PUT, so each test reads as a client's request rather than as
+  # Rack env assembly.
+  def put_request(id, body, headers = {})
+    request "/dav/addressbook/#{id}.vcf", { method: "PUT", input: body }.merge(headers)
+  end
+
+  VCARD = "text/vcard"
+
+  # RFC 6352 section 6.3.2: a PUT to an unmapped URI creates, and the
+  # strong ETag comes back because the stored bytes are the submitted
+  # bytes — the one case section 6.3.2.3 lets a client rely on it.
+  def test_put_creates_a_contact
+    card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:New\r\nUID:new\r\nEND:VCARD\r\n"
+
+    with_contacts({}) do |store|
+      put_request "new", card, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      assert_equal 201, last_response.status
+      assert_equal %("#{Digest::SHA256.hexdigest(card)}"), last_response["ETag"]
+      assert_empty last_response.body
+
+      get "/dav/addressbook/new.vcf"
+      assert_equal card, last_response.body
+
+      # The change-log entry a sync token counts on landed with the card.
+      change = store.changes.last
+      assert_equal "new", change.card_id
+      assert_equal "put", change.action
+    end
+  end
+
+  def test_put_with_if_none_match_star_refuses_an_existing_card
+    with_contacts({"aiden" => "Aiden"}) do
+      get "/dav/addressbook/aiden.vcf"
+      served = last_response.body
+
+      put_request "aiden", card("aiden", "Aiden Smith"),
+        "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+      assert_equal 412, last_response.status
+
+      get "/dav/addressbook/aiden.vcf"
+      assert_equal served, last_response.body
+    end
+  end
+
+  # If-None-Match is the client's SHOULD, not the server's requirement:
+  # a bare PUT to an unmapped URI creates all the same (RFC 6352
+  # section 6.3.2).
+  def test_put_without_a_conditional_creates
+    with_contacts({}) do
+      put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD
+
+      assert_equal 201, last_response.status
+    end
+  end
+
+  def test_put_with_the_current_etag_updates
+    with_contacts({"aiden" => "Aiden"}) do
+      get "/dav/addressbook/aiden.vcf"
+      stale = last_response["ETag"]
+
+      updated = card("aiden", "Aiden Smith")
+      put_request "aiden", updated, "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => stale
+
+      assert_equal 204, last_response.status
+      etag = %("#{Digest::SHA256.hexdigest(updated)}")
+      assert_equal etag, last_response["ETag"]
+
+      # The tag the PUT returned is the one every read now reports, which
+      # is what lets a client keep syncing against its own write.
+      get "/dav/addressbook/aiden.vcf"
+      assert_equal updated, last_response.body
+      assert_equal etag, last_response["ETag"]
+
+      request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+      assert_includes last_response.body, "<d:getetag>#{etag}</d:getetag>"
+    end
+  end
+
+  def test_put_with_a_stale_etag_is_refused_and_changes_nothing
+    with_contacts({"aiden" => "Aiden"}) do
+      get "/dav/addressbook/aiden.vcf"
+      before = last_response.body
+
+      put_request "aiden", card("aiden", "Aiden Smith"),
+        "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => %("#{"0" * 64}")
+
+      assert_equal 412, last_response.status
+
+      get "/dav/addressbook/aiden.vcf"
+      assert_equal before, last_response.body
+    end
+  end
+
+  # If-Match on a resource that does not exist fails rather than creating
+  # — the client named a representation it never saw (RFC 7232 section
+  # 3.1).
+  def test_put_with_if_match_against_nothing_is_refused
+    with_contacts({}) do
+      put_request "new", card("new", "New"),
+        "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => %("#{"0" * 64}")
+
+      assert_equal 412, last_response.status
+    end
+  end
+
+  # RFC 7232 section 3.1 allows If-Match a list of tags, and `*` for any
+  # current representation.
+  def test_if_match_accepts_a_list_and_a_star
+    with_contacts({"aiden" => "Aiden"}) do
+      get "/dav/addressbook/aiden.vcf"
+      etag = last_response["ETag"]
+      updated = card("aiden", "Aiden Smith")
+
+      put_request "aiden", updated, "CONTENT_TYPE" => VCARD,
+        "HTTP_IF_MATCH" => %("#{"0" * 64}", #{etag})
+      assert_equal 204, last_response.status
+
+      put_request "aiden", card("aiden", "Aiden"), "CONTENT_TYPE" => VCARD, "HTTP_IF_MATCH" => "*"
+      assert_equal 204, last_response.status
+    end
+  end
+
+  def test_put_without_a_conditional_overwrites
+    with_contacts({"aiden" => "Aiden"}) do
+      put_request "aiden", card("aiden", "Aiden Smith"), "CONTENT_TYPE" => VCARD
+
+      assert_equal 204, last_response.status
+
+      get "/dav/addressbook/aiden.vcf"
+      assert_includes last_response.body, "Aiden Smith"
+    end
+  end
+
+  # The preconditions of RFC 6352 section 6.3.2.1, marshalled as 412s
+  # in the DAV:error form RFC 4918 section 16 defines.
+  def test_put_of_a_non_vcard_media_type_names_its_precondition
+    with_contacts({}) do
+      put_request "new", card("new", "New"), "CONTENT_TYPE" => "text/plain"
+
+      assert_equal 412, last_response.status
+      assert_includes last_response.body, "<card:supported-address-data/>"
+    end
+  end
+
+  def test_put_of_bytes_that_are_not_utf_8_names_its_precondition
+    with_contacts({}) do
+      put_request "new", "\xFF\xFE".b, "CONTENT_TYPE" => VCARD
+
+      assert_equal 412, last_response.status
+      assert_includes last_response.body, "<card:valid-address-data/>"
+    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
+      assert_equal 412, last_response.status
+
+      # Lines that parse are still not a card without the envelope.
+      put_request "new", "FN:Nope\r\n", "CONTENT_TYPE" => VCARD
+      assert_equal 412, last_response.status
+
+      assert_includes last_response.body, "<card:valid-address-data/>"
+    end
+  end
+
+  def test_put_of_a_card_whose_uid_belongs_to_another_card
+    with_contacts({"aiden" => "Aiden"}) do
+      put_request "znorth", card("aiden", "Aiden"), "CONTENT_TYPE" => VCARD
+
+      assert_equal 412, last_response.status
+      assert_includes last_response.body, "<card:no-uid-conflict>"
+      # Where the UID already lives, which RFC 6352 section 6.3.2.1 asks
+      # the error to say.
+      assert_includes last_response.body, "<d:href>/dav/addressbook/aiden.vcf</d:href>"
+    end
+  end
+
+  # The id is the card's UID here, so a card whose UID names nothing at
+  # all is still a conflict: the href it was PUT to would not be the
+  # href it answers at.
+  def test_put_of_a_card_whose_uid_is_not_its_href
+    with_contacts({}) do
+      put_request "new", card("other", "New"), "CONTENT_TYPE" => VCARD
+
+      assert_equal 412, last_response.status
+      assert_includes last_response.body, "<card:no-uid-conflict/>"
+      refute_includes last_response.body, "d:href"
+    end
+  end
+
+  def test_put_of_a_card_with_no_uid
+    with_contacts({}) do
+      card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:New\r\nEND:VCARD\r\n"
+      put_request "new", card, "CONTENT_TYPE" => VCARD
+
+      assert_equal 412, last_response.status
+      assert_includes last_response.body, "<card:no-uid-conflict/>"
+    end
+  end
+
+  # RFC 6352 section 6.3.2.2: what the server does not model survives
+  # the trip, because the submitted bytes are the stored bytes.
+  def test_put_stores_what_it_was_sent_verbatim
+    card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:New\r\nX-APPLE-FOO:kept\r\nUID:new\r\nEND:VCARD\r\n"
+
+    with_contacts({}) do
+      put_request "new", card, "CONTENT_TYPE" => VCARD
+
+      get "/dav/addressbook/new.vcf"
+      assert_equal card, last_response.body
+    end
+  end
+
+  # A last segment that is not the <id>.vcf shape can address no
+  # resource here, created or read — the 404 the GET handler gives it.
+  def test_put_to_a_uri_that_cannot_address_a_card
+    with_contacts({}) do
+      request "/dav/addressbook/bad.id.vcf",
+        method: "PUT", input: card("bad.id", "Bad"), "CONTENT_TYPE" => VCARD
+
+      assert_equal 404, last_response.status
+    end
+  end
+
   # A card as Contacts would send one, so the routes are exercised
   # against stored bytes rather than anything this test renders.
   def card(id, name)