dav: build response bodies with a builder, not heredocs
The heredocs sent each CRLF raw, and a client's parser kept only the LF;
the builder writes CR as 
, so the card arrives as itself.
An element leaves only through a DavXml method, one per name;
the other fixtures only reindent — what they say is the same.

Assisted-by: Claude Opus 5 via Claude Code
change utnwrwuktxowwzsvnmvoqvqlvuqwpywu
commit 334740fae92521054e42e8085aad5d4c9de08426
author Alpha Chen <alpha@kejadlen.dev>
date
parent usnmopmv
diff --git a/AGENTS.md b/AGENTS.md
index a7d4adc..c73709b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,6 +41,8 @@ lib/pro_tacts/
 │                   # each first segment is a hash_branch in web/
 ├── web/dav.rb      # Discovery and the address book: every DAV route
 │                   # and response body
+├── dav_xml.rb      # The elements those bodies are written with, one
+│                   # object per namespace
 ├── web/contacts.rb # The card browser and its editor
 ├── web/groups.rb   # The group screens and their editor
 ├── web/setup.rb    # The device setup screen and its profile
@@ -87,9 +89,10 @@ not a fixture; edit a `.vcf` to change what the replay serves.
 
 ## Gotchas
 
-- Response bodies are built with heredocs in `web/dav.rb`. A comment written
-  inside one is sent to the client — keep notes about the code in Ruby
-  comments outside the heredoc.
+- Response bodies in `web/dav.rb` are built with Nokogiri's builder through
+  `DavXml`, whose methods are the only elements the server can send. Inside
+  an element's block, `it` is the builder, not an enclosing block's value,
+  so name the outer block's parameter.
 - Every request needs `Tailscale-User-Login` and `Tailscale-User-Name`
   headers or it gets a 401, so a bare `curl` against `rake dev` is
   refused until you pass both. The
diff --git a/lib/pro_tacts/dav_xml.rb b/lib/pro_tacts/dav_xml.rb
new file mode 100644
index 0000000..83e5933
--- /dev/null
+++ b/lib/pro_tacts/dav_xml.rb
@@ -0,0 +1,154 @@
+require "nokogiri"
+
+module ProTacts
+  # The XML the DAV routes answer with (web/dav.rb). Each namespace is an
+  # object whose methods are the elements this server sends in it, so an
+  # element outside them is a NoMethodError, and adding one is a change
+  # here — the real decision AGENTS.md says adding a property is.
+  module DavXml
+    PREFIXES = {
+      "d" => "DAV:",
+      "card" => "urn:ietf:params:xml:ns:carddav",
+      "cs" => "http://calendarserver.org/ns/",
+    }.freeze #: Hash[String, String]
+
+    # A whole body under the DAV: element root, declaring "d" and each of
+    # prefixes, with the children the block writes. Writing through a
+    # namespace left undeclared raises.
+    #: (String root, *String prefixes) { (DAV d, CardDAV card, CalendarServer cs) -> void } -> String
+    def self.document(root, *prefixes)
+      namespaces = ["d", *prefixes].to_h do |prefix|
+        ["xmlns:#{prefix}", PREFIXES.fetch(prefix)] #: [String, String]
+      end
+
+      Nokogiri::XML::Builder.new(encoding: "UTF-8") { |x|
+        x["d"].send(root, namespaces) do
+          yield DAV.new(x, "d"), CardDAV.new(x, "card"), CalendarServer.new(x, "cs")
+        end
+      }.to_xml
+    end
+
+    # One namespace's elements. The builder yields itself to an element's
+    # block, so `it` inside one is the builder rather than an enclosing
+    # block's element.
+    class Namespace
+      # @rbs @x: Nokogiri::XML::Builder
+      # @rbs @prefix: String
+
+      #: (Nokogiri::XML::Builder x, String prefix) -> void
+      def initialize(x, prefix)
+        @x = x
+        @prefix = prefix
+      end
+
+      private
+
+      #: (String name, ?String? text) ?{ () -> void } -> void
+      def tag(name, text = nil, &)
+        element = @x[@prefix]
+        text ? element.send(name, text, &) : element.send(name, &)
+      end
+    end
+
+    class DAV < Namespace
+      # A member answered with a 200, holding the properties the block
+      # writes.
+      #: (String location) { () -> void } -> void
+      def found(location, &)
+        response do
+          href location
+          propstat do
+            prop(&)
+            status "HTTP/1.1 200 OK"
+          end
+        end
+      end
+
+      # A member the collection does not answer for: the multiget miss,
+      # reported as a 404 inside the 207 rather than failing the request
+      # (RFC 6352 section 8.7), and the removal shape a sync-collection
+      # delta reports (RFC 6578 section 3.2 — href and 404, no propstat).
+      #: (String location) -> void
+      def missing(location)
+        response do
+          href location
+          status "HTTP/1.1 404 Not Found"
+        end
+      end
+
+      #: () { () -> void } -> void
+      def response(&) = tag("response", &)
+      #: () { () -> void } -> void
+      def propstat(&) = tag("propstat", &)
+      #: () { () -> void } -> void
+      def prop(&) = tag("prop", &)
+      #: (String location) -> void
+      def href(location) = tag("href", location)
+      #: (String line) -> void
+      def status(line) = tag("status", line)
+
+      #: (String etag) -> void
+      def getetag(etag) = tag("getetag", etag)
+      #: (String token) -> void
+      def sync_token(token) = tag("sync-token", token)
+      #: () { () -> void } -> void
+      def current_user_principal(&) = tag("current-user-principal", &)
+      #: () { () -> void } -> void
+      def resourcetype(&) = tag("resourcetype", &)
+      #: () -> void
+      def collection = tag("collection")
+
+      #: () { () -> void } -> void
+      def supported_report_set(&) = tag("supported-report-set", &)
+      # Also the precondition RFC 3253 section 3.6 names, empty.
+      #: () ?{ () -> void } -> void
+      def supported_report(&) = tag("supported-report", &)
+      #: () { () -> void } -> void
+      def report(&) = tag("report", &)
+      #: () -> void
+      def sync_collection = tag("sync-collection")
+      #: () -> void
+      def valid_sync_token = tag("valid-sync-token")
+
+      #: () { () -> void } -> void
+      def current_user_privilege_set(&) = tag("current-user-privilege-set", &)
+      #: () { () -> void } -> void
+      def privilege(&) = tag("privilege", &)
+      #: () -> void
+      def read = tag("read")
+      #: () -> void
+      def write = tag("write")
+      #: () -> void
+      def bind = tag("bind")
+      #: () -> void
+      def unbind = tag("unbind")
+    end
+
+    class CardDAV < Namespace
+      #: () { () -> void } -> void
+      def addressbook_home_set(&) = tag("addressbook-home-set", &)
+      #: () -> void
+      def addressbook = tag("addressbook")
+
+      # The card's carriage returns go out as character references: a raw
+      # CR in text is normalized away by the client's parser (XML 1.0
+      # section 2.11), which would hand it LF-only lines.
+      #: (String card) -> void
+      def address_data(card) = tag("address-data", card)
+
+      # The preconditions of RFC 6352 section 6.3.2.1, each holding what
+      # the block writes.
+      #: () ?{ () -> void } -> void
+      def supported_address_data(&) = tag("supported-address-data", &)
+      #: () ?{ () -> void } -> void
+      def valid_address_data(&) = tag("valid-address-data", &)
+      #: () ?{ () -> void } -> void
+      def no_uid_conflict(&) = tag("no-uid-conflict", &)
+    end
+
+    class CalendarServer < Namespace
+      #: (String ctag) -> void
+      def getctag(ctag) = tag("getctag", ctag)
+    end
+  end
+end
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 685109a..59ad7ce 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -1,6 +1,8 @@
 require "digest"
 require "nokogiri"
 
+require "pro_tacts/dav_xml"
+
 module ProTacts
   # The CardDAV half of the router: service discovery and the one
   # address book each user syncs (docs/plans/2026-09-12-per-user-books.md).
@@ -42,22 +44,11 @@ module ProTacts
           response["Content-Type"] = "text/xml"
           response.status = 207
 
-          <<~XML
-            <?xml version="1.0" encoding="UTF-8"?>
-            <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
-              <d:response>
-                <d:href>/dav/principal/</d:href>
-                <d:propstat>
-                  <d:prop>
-                    <card:addressbook-home-set>
-                      <d:href>/dav/addressbook/</d:href>
-                    </card:addressbook-home-set>
-                  </d:prop>
-                  <d:status>HTTP/1.1 200 OK</d:status>
-                </d:propstat>
-              </d:response>
-            </d:multistatus>
-          XML
+          multistatus("card") do |d, card|
+            d.found("/dav/principal/") do
+              card.addressbook_home_set { d.href "/dav/addressbook/" }
+            end
+          end
         end
       end
 
@@ -75,72 +66,57 @@ module ProTacts
 
           etag_only = body.include?("getetag") && !body.include?("displayname") && !body.include?("resourcetype")
 
-          # The collection self-entry is omitted from an etag-only ask
-          # until a client is found to need it.
-          collection_response = ""
-          unless etag_only
-            # Every property in this body and where it comes from:
-            # DAV:resourcetype, which an address book collection MUST report
-            # as both collection and addressbook (RFC 6352 section 5.2);
-            # DAV:supported-report-set (RFC 3253 section 3.1.5), which RFC
-            # 6578 section 3.2 requires list sync-collection;
-            # DAV:sync-token (RFC 6578 section 4); and
-            # DAV:current-user-privilege-set (RFC 3744 section 5.4), which
-            # RFC 6352 section 7 requires of a CardDAV server. getctag alone
-            # is not standardized — an Apple CalendarServer extension in the
-            # calendarserver.org namespace, kept because macOS polls it.
-            #
-            # 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:unbind is removing a member from the
-            # collection (section 3.10), which DELETE answers.
-            # DAV:bind is adding one (section 3.9), and the create it
-            # names — POST to the collection with DAV:add-member — has
-            # no route: PUT to the member URI is how both clients
-            # create, so nothing has asked for it.
-            collection_response = <<~XML
-              <d:response>
-                <d:href>/dav/addressbook/</d:href>
-                <d:propstat>
-                  <d:prop>
-                    <d:resourcetype>
-                      <d:collection/>
-                      <card:addressbook/>
-                    </d:resourcetype>
-                    <d:supported-report-set>
-                      <d:supported-report>
-                        <d:report><d:sync-collection/></d:report>
-                      </d:supported-report>
-                    </d:supported-report-set>
-                    <cs:getctag>#{ctag}</cs:getctag>
-                    <d:sync-token>#{sync_token}</d:sync-token>
-                    <d:current-user-privilege-set>
-                      <d:privilege><d:read/></d:privilege>
-                      <d:privilege><d:write/></d:privilege>
-                      <d:privilege><d:bind/></d:privilege>
-                      <d:privilege><d:unbind/></d:privilege>
-                    </d:current-user-privilege-set>
-                  </d:prop>
-                  <d:status>HTTP/1.1 200 OK</d:status>
-                </d:propstat>
-              </d:response>
-            XML
-          end
-
-          members = depth == "0" ? "" : contacts.map { etag_response(it) }.join
+          multistatus("card", "cs") do |d, card, cs|
+            # The collection self-entry is omitted from an etag-only ask
+            # until a client is found to need it.
+            unless etag_only
+              # Every property in this body and where it comes from:
+              # DAV:resourcetype, which an address book collection MUST report
+              # as both collection and addressbook (RFC 6352 section 5.2);
+              # DAV:supported-report-set (RFC 3253 section 3.1.5), which RFC
+              # 6578 section 3.2 requires list sync-collection;
+              # DAV:sync-token (RFC 6578 section 4); and
+              # DAV:current-user-privilege-set (RFC 3744 section 5.4), which
+              # RFC 6352 section 7 requires of a CardDAV server. getctag alone
+              # is not standardized — an Apple CalendarServer extension in the
+              # calendarserver.org namespace, kept because macOS polls it.
+              #
+              # 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:unbind is removing a member from the
+              # collection (section 3.10), which DELETE answers.
+              # DAV:bind is adding one (section 3.9), and the create it
+              # names — POST to the collection with DAV:add-member — has
+              # no route: PUT to the member URI is how both clients
+              # create, so nothing has asked for it.
+              d.found("/dav/addressbook/") do
+                d.resourcetype do
+                  d.collection
+                  card.addressbook
+                end
+                d.supported_report_set do
+                  d.supported_report do
+                    d.report { d.sync_collection }
+                  end
+                end
+                cs.getctag ctag
+                d.sync_token sync_token
+                d.current_user_privilege_set do
+                  d.privilege { d.read }
+                  d.privilege { d.write }
+                  d.privilege { d.bind }
+                  d.privilege { d.unbind }
+                end
+              end
+            end
 
-          <<~XML
-            <?xml version="1.0" encoding="UTF-8"?>
-            <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
-              #{collection_response}
-              #{members}
-            </d:multistatus>
-          XML
+            contacts.each { etag_response(d, it) } unless depth == "0"
+          end
         end
 
         r.report do
@@ -193,15 +169,18 @@ module ProTacts
             if token.empty?
               # No token is the initial sync: every member, changed
               # (section 3.4).
-              multistatus(contacts.map { etag_response(it) }, sync_token:)
+              multistatus("card", sync_token:) do |d|
+                contacts.each { etag_response(d, it) }
+              end
             elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)/#{book_digest}\z}, 1]) &&
                 sequence.to_i <= store.latest_sequence
               net = store.changes(after: sequence.to_i).map { it.card_id }.uniq
-              responses = net.map { |id|
-                contact = contacts.find { it.id == id }
-                contact ? etag_response(contact) : missing_response(contact_href(id))
-              }
-              multistatus(responses, sync_token:)
+              multistatus("card", sync_token:) do |d|
+                net.each do |id|
+                  contact = contacts.find { it.id == id }
+                  contact ? etag_response(d, contact) : d.missing(contact_href(id))
+                end
+              end
             else
               # A token this server never issued, one issued for another
               # book, or one naming a state past the present: the
@@ -212,28 +191,25 @@ module ProTacts
               # token names is gone.
               response.status = 410
 
-              <<~XML
-                <?xml version="1.0" encoding="UTF-8"?>
-                <d:error xmlns:d="DAV:">
-                  <d:valid-sync-token/>
-                </d:error>
-              XML
+              DavXml.document("error") { |d| d.valid_sync_token }
             end
           when "addressbook-multiget"
             # CARDDAV:addressbook-multiget (RFC 6352 section 8.7); the
             # address-data the client asks for is section 10.4.
             wants_cards = doc.xpath("//address-data").any?
 
-            multistatus(doc.xpath("//href").map { it.text }.map { |requested|
-              id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
-              contact = id && contacts.find { it.id == id }
+            multistatus("card") do |d, card|
+              doc.xpath("//href").map { it.text }.each do |requested|
+                id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
+                contact = id && contacts.find { it.id == id }
 
-              if contact
-                wants_cards ? card_response(contact) : etag_response(contact)
-              else
-                missing_response(requested)
+                if contact
+                  wants_cards ? card_response(d, card, contact) : etag_response(d, contact)
+                else
+                  d.missing(requested)
+                end
               end
-            })
+            end
           else
             # The DAV:supported-report precondition on REPORT (RFC 3253
             # section 3.6) — the report asked for has to be one the
@@ -250,12 +226,7 @@ module ProTacts
             # macOS Contacts has never sent one.
             response.status = 403
 
-            <<~XML
-              <?xml version="1.0" encoding="UTF-8"?>
-              <d:error xmlns:d="DAV:">
-                <d:supported-report/>
-              </d:error>
-            XML
+            DavXml.document("error") { |d| d.supported_report }
           end
         end
 
@@ -307,22 +278,11 @@ module ProTacts
       response["Content-Type"] = "text/xml"
       response.status = 207
 
-      <<~XML
-        <?xml version="1.0" encoding="UTF-8"?>
-        <d:multistatus xmlns:d="DAV:">
-          <d:response>
-            <d:href>#{href}</d:href>
-            <d:propstat>
-              <d:prop>
-                <d:current-user-principal>
-                  <d:href>/dav/principal/</d:href>
-                </d:current-user-principal>
-              </d:prop>
-              <d:status>HTTP/1.1 200 OK</d:status>
-            </d:propstat>
-          </d:response>
-        </d:multistatus>
-      XML
+      multistatus do |d|
+        d.found(href) do
+          d.current_user_principal { d.href "/dav/principal/" }
+        end
+      end
     end
 
     # Read once per request — Roda builds a fresh app instance for each
@@ -391,7 +351,7 @@ module ProTacts
       # 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"
+      return precondition(:supported_address_data) unless request.media_type == "text/vcard"
 
       # Decoding the body, both halves of it: Rack requires input in
       # ASCII-8BIT and Rack::RewindableInput enforces it again, so the
@@ -407,7 +367,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 bytes.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
@@ -416,7 +376,7 @@ module ProTacts
       # what it does not understand, and it is kept — the stored bytes
       # are what goes back out.
       vcard = VCard.new(bytes)
-      return precondition("valid-address-data") unless vcard.card?
+      return precondition(:valid_address_data) unless vcard.card?
 
       # CARDDAV:no-uid-conflict: the submitted UID must not belong to a
       # different resource, and a mapped URI must not be overwritten by
@@ -427,7 +387,7 @@ module ProTacts
       # the first clause — report where the UID already lives.
       uid = vcard.uid
       owner = uid && store.card_id_with_uid(uid)
-      return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
+      return precondition(:no_uid_conflict, owner) if uid != id || owner && owner != id
 
       existing = store.contact(id)
 
@@ -489,7 +449,7 @@ module ProTacts
 
       # The change-log entry Store#delete leaves in the same transaction
       # is what a syncing client is told: sync-collection answers a
-      # removed member as href plus 404 (#missing_response).
+      # removed member as href plus 404 (DavXml::DAV#missing).
       store.delete(id)
       response.status = 204
 
@@ -544,21 +504,15 @@ module ProTacts
     # 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
+    #: (:supported_address_data | :valid_address_data | :no_uid_conflict 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
+      DavXml.document("error", "card") do |d, card|
+        card.public_send(element) do
+          d.href contact_href(conflict_id) if conflict_id
+        end
+      end
     end
 
     # A bare 412: these failures are HTTP's own conditionals (RFC 7232),
@@ -593,15 +547,12 @@ module ProTacts
     # A 207 body. The trailing DAV:sync-token is RFC 6578 section 3.2's
     # — a sync-collection response MUST carry one naming the state the
     # answer reaches; no other multistatus here has a state to name.
-    #: (Array[String] responses, ?sync_token: String) -> String
-    def multistatus(responses, sync_token: nil)
-      trailing = sync_token ? "  <d:sync-token>#{sync_token}</d:sync-token>\n" : ""
-      <<~XML
-        <?xml version="1.0" encoding="UTF-8"?>
-        <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
-          #{responses.join}#{trailing}
-        </d:multistatus>
-      XML
+    #: (*String prefixes, ?sync_token: String?) { (DavXml::DAV d, DavXml::CardDAV card, DavXml::CalendarServer cs) -> void } -> String
+    def multistatus(*prefixes, sync_token: nil)
+      DavXml.document("multistatus", *prefixes) do |d, card, cs|
+        yield d, card, cs
+        d.sync_token sync_token if sync_token
+      end
     end
 
     #: (String id) -> String
@@ -610,56 +561,17 @@ module ProTacts
     end
 
     # DAV:getetag (RFC 4918 section 15.6).
-    #: (Contact contact) -> String
-    def etag_response(contact)
-      <<~XML
-        <d:response>
-          <d:href>#{contact_href(contact.id)}</d:href>
-          <d:propstat>
-            <d:prop>
-              <d:getetag>#{contact.etag}</d:getetag>
-            </d:prop>
-            <d:status>HTTP/1.1 200 OK</d:status>
-          </d:propstat>
-        </d:response>
-      XML
+    #: (DavXml::DAV d, Contact contact) -> void
+    def etag_response(d, contact)
+      d.found(contact_href(contact.id)) { d.getetag contact.etag }
     end
 
-    #: (Contact contact) -> String
-    def card_response(contact)
-      <<~XML
-        <d:response>
-          <d:href>#{contact_href(contact.id)}</d:href>
-          <d:propstat>
-            <d:prop>
-              <d:getetag>#{contact.etag}</d:getetag>
-              <card:address-data>#{xml_escape(contact.vcard.to_s.chomp)}</card:address-data>
-            </d:prop>
-            <d:status>HTTP/1.1 200 OK</d:status>
-          </d:propstat>
-        </d:response>
-      XML
-    end
-
-    # A member the collection does not answer for: the multiget miss,
-    # reported as a 404 inside the 207 rather than failing the request
-    # (RFC 6352 section 8.7), and the removal shape a sync-collection
-    # delta reports (RFC 6578 section 3.2 — href and 404, no propstat).
-    #: (String requested) -> String
-    def missing_response(requested)
-      <<~XML
-        <d:response>
-          <d:href>#{xml_escape(requested)}</d:href>
-          <d:status>HTTP/1.1 404 Not Found</d:status>
-        </d:response>
-      XML
-    end
-
-    # Text nodes in XML built by interpolation; hrefs and vCard content
-    # can all contain &, <, or >.
-    #: (String text) -> String
-    def xml_escape(text)
-      text.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
+    #: (DavXml::DAV d, DavXml::CardDAV card, Contact contact) -> void
+    def card_response(d, card, contact)
+      d.found(contact_href(contact.id)) do
+        d.getetag contact.etag
+        card.address_data contact.vcard.to_s.chomp
+      end
     end
   end
 end
diff --git a/sig/gems/nokogiri.rbs b/sig/gems/nokogiri.rbs
index b7798cf..6aa933b 100644
--- a/sig/gems/nokogiri.rbs
+++ b/sig/gems/nokogiri.rbs
@@ -1,5 +1,8 @@
 # The nokogiri surface pro-tacts uses to read REPORT bodies: parse, drop
-# the namespaces, and pull elements out by xpath.
+# the namespaces, and pull elements out by xpath. And to write the DAV
+# bodies, through a Builder whose elements are method_missing calls,
+# which DavXml::Namespace#tag makes with send so nothing else needs
+# declaring.
 #
 # `root` is declared non-nil because Nokogiri::XML only returns a
 # document without one for input that is not XML at all, and web.rb
@@ -24,5 +27,11 @@ module Nokogiri
 
       def each: () { (Node) -> void } -> void
     end
+
+    class Builder
+      def self.new: (?encoding: String) { (Builder) -> void } -> Builder
+      def []: (String prefix) -> Builder
+      def to_xml: () -> String
+    end
   end
 end
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index d298041..3dbff83 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -4,30 +4,38 @@ Content-Type: text/xml
 <?xml version="1.0" encoding="UTF-8"?>
 <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
   <d:response>
-  <d:href>/dav/addressbook/</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:resourcetype>
-        <d:collection/>
-        <card:addressbook/>
-      </d:resourcetype>
-      <d:supported-report-set>
-        <d:supported-report>
-          <d:report><d:sync-collection/></d:report>
-        </d:supported-report>
-      </d:supported-report-set>
-      <cs:getctag>19</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
-      <d:current-user-privilege-set>
-        <d:privilege><d:read/></d:privilege>
-        <d:privilege><d:write/></d:privilege>
-        <d:privilege><d:bind/></d:privilege>
-        <d:privilege><d:unbind/></d:privilege>
-      </d:current-user-privilege-set>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
-  
+    <d:href>/dav/addressbook/</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:resourcetype>
+          <d:collection/>
+          <card:addressbook/>
+        </d:resourcetype>
+        <d:supported-report-set>
+          <d:supported-report>
+            <d:report>
+              <d:sync-collection/>
+            </d:report>
+          </d:supported-report>
+        </d:supported-report-set>
+        <cs:getctag>19</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:current-user-privilege-set>
+          <d:privilege>
+            <d:read/>
+          </d:privilege>
+          <d:privilege>
+            <d:write/>
+          </d:privilege>
+          <d:privilege>
+            <d:bind/>
+          </d:privilege>
+          <d:privilege>
+            <d:unbind/>
+          </d:privilege>
+        </d:current-user-privilege-set>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
 </d:multistatus>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index ee3f601..55254af 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -4,192 +4,200 @@ Content-Type: text/xml
 <?xml version="1.0" encoding="UTF-8"?>
 <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
   <d:response>
-  <d:href>/dav/addressbook/</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:resourcetype>
-        <d:collection/>
-        <card:addressbook/>
-      </d:resourcetype>
-      <d:supported-report-set>
-        <d:supported-report>
-          <d:report><d:sync-collection/></d:report>
-        </d:supported-report>
-      </d:supported-report-set>
-      <cs:getctag>19</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
-      <d:current-user-privilege-set>
-        <d:privilege><d:read/></d:privilege>
-        <d:privilege><d:write/></d:privilege>
-        <d:privilege><d:bind/></d:privilege>
-        <d:privilege><d:unbind/></d:privilege>
-      </d:current-user-privilege-set>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
+    <d:href>/dav/addressbook/</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:resourcetype>
+          <d:collection/>
+          <card:addressbook/>
+        </d:resourcetype>
+        <d:supported-report-set>
+          <d:supported-report>
+            <d:report>
+              <d:sync-collection/>
+            </d:report>
+          </d:supported-report>
+        </d:supported-report-set>
+        <cs:getctag>19</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:current-user-privilege-set>
+          <d:privilege>
+            <d:read/>
+          </d:privilege>
+          <d:privilege>
+            <d:write/>
+          </d:privilege>
+          <d:privilege>
+            <d:bind/>
+          </d:privilege>
+          <d:privilege>
+            <d:unbind/>
+          </d:privilege>
+        </d:current-user-privilege-set>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
   <d:response>
-  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-complete.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/emoji.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-alicia.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-george.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-mary.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/memoji.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/photo.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
+    <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-complete.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/emoji.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-alicia.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-george.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-mary.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/memoji.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/photo.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
 </d:multistatus>
diff --git a/test/fixtures/ios-exchange/04-report-multiget/response b/test/fixtures/ios-exchange/04-report-multiget/response
index a023f27..ea0f3c3 100644
--- a/test/fixtures/ios-exchange/04-report-multiget/response
+++ b/test/fixtures/ios-exchange/04-report-multiget/response
@@ -4,19 +4,18 @@ Content-Type: text/xml
 <?xml version="1.0" encoding="UTF-8"?>
 <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
   <d:response>
-  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
-      <card:address-data>BEGIN:VCARD
-VERSION:3.0
-N:Contact;Test;;;
-FN:Test Contact
-UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+    <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
+        <card:address-data>BEGIN:VCARD&#13;
+VERSION:3.0&#13;
+N:Contact;Test;;;&#13;
+FN:Test Contact&#13;
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF&#13;
 END:VCARD</card:address-data>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
 </d:multistatus>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index d298041..3dbff83 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -4,30 +4,38 @@ Content-Type: text/xml
 <?xml version="1.0" encoding="UTF-8"?>
 <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
   <d:response>
-  <d:href>/dav/addressbook/</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:resourcetype>
-        <d:collection/>
-        <card:addressbook/>
-      </d:resourcetype>
-      <d:supported-report-set>
-        <d:supported-report>
-          <d:report><d:sync-collection/></d:report>
-        </d:supported-report>
-      </d:supported-report-set>
-      <cs:getctag>19</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
-      <d:current-user-privilege-set>
-        <d:privilege><d:read/></d:privilege>
-        <d:privilege><d:write/></d:privilege>
-        <d:privilege><d:bind/></d:privilege>
-        <d:privilege><d:unbind/></d:privilege>
-      </d:current-user-privilege-set>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
-  
+    <d:href>/dav/addressbook/</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:resourcetype>
+          <d:collection/>
+          <card:addressbook/>
+        </d:resourcetype>
+        <d:supported-report-set>
+          <d:supported-report>
+            <d:report>
+              <d:sync-collection/>
+            </d:report>
+          </d:supported-report>
+        </d:supported-report-set>
+        <cs:getctag>19</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:current-user-privilege-set>
+          <d:privilege>
+            <d:read/>
+          </d:privilege>
+          <d:privilege>
+            <d:write/>
+          </d:privilege>
+          <d:privilege>
+            <d:bind/>
+          </d:privilege>
+          <d:privilege>
+            <d:unbind/>
+          </d:privilege>
+        </d:current-user-privilege-set>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
 </d:multistatus>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 9e9de80..8a5c4df 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -3,168 +3,166 @@ Content-Type: text/xml
 
 <?xml version="1.0" encoding="UTF-8"?>
 <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
-  
-  <d:response>
-  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-complete.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/emoji.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-alicia.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-george.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-mary.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/memoji.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/photo.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
+  <d:response>
+    <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-complete.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/emoji.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-alicia.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-george.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-mary.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/memoji.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/photo.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
 </d:multistatus>
diff --git a/test/fixtures/macos-exchange/07-report-multiget/response b/test/fixtures/macos-exchange/07-report-multiget/response
index a023f27..ea0f3c3 100644
--- a/test/fixtures/macos-exchange/07-report-multiget/response
+++ b/test/fixtures/macos-exchange/07-report-multiget/response
@@ -4,19 +4,18 @@ Content-Type: text/xml
 <?xml version="1.0" encoding="UTF-8"?>
 <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
   <d:response>
-  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
-      <card:address-data>BEGIN:VCARD
-VERSION:3.0
-N:Contact;Test;;;
-FN:Test Contact
-UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+    <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
+        <card:address-data>BEGIN:VCARD&#13;
+VERSION:3.0&#13;
+N:Contact;Test;;;&#13;
+FN:Test Contact&#13;
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF&#13;
 END:VCARD</card:address-data>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
 </d:multistatus>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index ee3f601..55254af 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -4,192 +4,200 @@ Content-Type: text/xml
 <?xml version="1.0" encoding="UTF-8"?>
 <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
   <d:response>
-  <d:href>/dav/addressbook/</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:resourcetype>
-        <d:collection/>
-        <card:addressbook/>
-      </d:resourcetype>
-      <d:supported-report-set>
-        <d:supported-report>
-          <d:report><d:sync-collection/></d:report>
-        </d:supported-report>
-      </d:supported-report-set>
-      <cs:getctag>19</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
-      <d:current-user-privilege-set>
-        <d:privilege><d:read/></d:privilege>
-        <d:privilege><d:write/></d:privilege>
-        <d:privilege><d:bind/></d:privilege>
-        <d:privilege><d:unbind/></d:privilege>
-      </d:current-user-privilege-set>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
+    <d:href>/dav/addressbook/</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:resourcetype>
+          <d:collection/>
+          <card:addressbook/>
+        </d:resourcetype>
+        <d:supported-report-set>
+          <d:supported-report>
+            <d:report>
+              <d:sync-collection/>
+            </d:report>
+          </d:supported-report>
+        </d:supported-report-set>
+        <cs:getctag>19</cs:getctag>
+        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:current-user-privilege-set>
+          <d:privilege>
+            <d:read/>
+          </d:privilege>
+          <d:privilege>
+            <d:write/>
+          </d:privilege>
+          <d:privilege>
+            <d:bind/>
+          </d:privilege>
+          <d:privilege>
+            <d:unbind/>
+          </d:privilege>
+        </d:current-user-privilege-set>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
   <d:response>
-  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-complete.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/emoji.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-alicia.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-george.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/household-mary.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/memoji.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-<d:response>
-  <d:href>/dav/addressbook/photo.vcf</d:href>
-  <d:propstat>
-    <d:prop>
-      <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
-    </d:prop>
-    <d:status>HTTP/1.1 200 OK</d:status>
-  </d:propstat>
-</d:response>
-
+    <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/D0504597-CF12-4AF6-9894-43724366D39D.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"6ce139775700c82011936b1958b6e081d62f27c711931804ecac2a1e5bb6e8f9"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-complete.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"4f18a47439816f1488e1bea6ddd31b26dd098de0e5245e57e70e32944be12804"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-datetime.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"e05100ccdcc77884ab0cd415544df3be9caaed41b37cf6f15b1675462e92bc14"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-day-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-month-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-omit-year.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"fe423fff45d51c9c49cc25d667a9fd2ee3f9f53dbb2be12acad15f1088b5c2a0"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-year-month.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/bday-year-only.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/emoji.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"f2de8c9cbbc34e304510ff6f076ed3275e620802bc15731550b5d43b0d0ae994"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-alicia.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"ca751ac9f3b3be48e95809594bfcfcce2774fac924698e4e6fdf342645df2af8"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-george.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"89143354379038718238e5f146f71535724c00cc28d9f21add2f864e62b3953e"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/household-mary.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"a94d2aeb791106372057f2759864f46ea78e1fe410cacbe15026b303e1bb174f"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/memoji.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"bb0734ee418c5bb60047503ea7675ccb46151d8e0084af665ba628bfc4acd804"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/oqmvpzooywwmtwwx.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"e63c84519663074659f6cd80ba7c4de31fe7c72c87736cbf630619bb0448d1cd"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
+  <d:response>
+    <d:href>/dav/addressbook/photo.vcf</d:href>
+    <d:propstat>
+      <d:prop>
+        <d:getetag>"9bd93308c95dc178599a5102bdc6f194f8cb02f838262f421fbe7e2d7c1ab281"</d:getetag>
+      </d:prop>
+      <d:status>HTTP/1.1 200 OK</d:status>
+    </d:propstat>
+  </d:response>
 </d:multistatus>
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 8bcd2c4..3a7fd67 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -718,8 +718,9 @@ class WebTest < Minitest::Test
       request "/dav/addressbook/", method: "REPORT", input: multiget("new")
 
       assert_equal 207, last_response.status
-      served = last_response.body[%r{<card:address-data>(.*)</card:address-data>}m, 1]
-      assert_equal card.chomp, xml_unescape(served)
+      served = Nokogiri::XML(last_response.body)
+        .at_xpath("//card:address-data", "card" => "urn:ietf:params:xml:ns:carddav").text
+      assert_equal card.chomp, served
     end
   end
 
@@ -773,12 +774,6 @@ class WebTest < Minitest::Test
     end
   end
 
-  # The route's xml_escape, reversed, for reading a card back out of
-  # a multiget body and comparing it with what was PUT.
-  def xml_unescape(text)
-    text.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
-  end
-
   # Contacts by id and name, each a bare card, and every one in `sync:*`
   # so the requester's book is all of them. Seeded without a change-log
   # entry, so the sequences the sync tests read are the puts' alone.