Serve contacts from KDL files on disk
The fixture contact keeps the ID from the recorded macOS exchange so
the byte-for-byte replay still resolves the hrefs the client actually
requested.

Assisted-by: GLM-5.3 via pi
change qzwsvvksrpzxxtlyrvymkrpuuzwkumom
commit 70b16d7c879b74c11c2001cddb67dc9d588f5437
author Alpha Chen <alpha@kejadlen.dev>
date
parent kvqukxkq
diff --git a/.gitignore b/.gitignore
index e6a5295..8533978 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
 /servers
 /carddav.mobileconfig
 /bin
+/data
diff --git a/README.md b/README.md
index f53eeb2..fed7ef5 100644
--- a/README.md
+++ b/README.md
@@ -17,12 +17,23 @@ A CardDAV server for my family.
 
 ## Status
 
-A read-only skeleton. Every response in `lib/pro_tacts/web.rb` is hardcoded:
-one principal, one address book, one vCard. Nothing is parsed, stored, or
-looked up yet. macOS Contacts displays the hardcoded card over Tailscale
-serve as of 2026-08-14, so 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.
+Read-only and serving real data. Contacts live as KDL files under
+`data/contacts` (override with `PRO_TACTS_CONTACTS_DIR`), one contact per
+file, the filename doubling as the contact ID and the vCard UID:
+
+```kdl
+contact {
+    name "John Smith"
+    phone "+1-555-1234" type="mobile"
+    email "john@example.com"
+}
+```
+
+macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
+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. Still placeholders: etags and ctags are constants rather
+than derived from file state, and there is no authentication yet.
 
 ## The minimal set macOS Contacts needs
 
@@ -82,7 +93,7 @@ implementing it means reading several RFCs together:
 [rfc6350]: https://datatracker.ietf.org/doc/html/rfc6350
 
 RFC 6352 requires an address book collection to support vCard 3.0 and treats
-4.0 as optional, which is why the hardcoded card here is `VERSION:3.0` even
+4.0 as optional, which is why contacts are rendered as `VERSION:3.0` even
 though `docs/plans/2026-01-12-carddav-reference.md` shows 4.0 examples.
 
 Two properties macOS depends on are not in any RFC. They come from Apple's
diff --git a/Rakefile b/Rakefile
index 5006457..8a4365a 100644
--- a/Rakefile
+++ b/Rakefile
@@ -19,6 +19,9 @@ end
 desc "Regenerate macOS exchange response fixtures from current responses"
 task :fixtures do
   ENV["RACK_ENV"] = "test"
+  # Mirrors test/test_helper.rb, which cannot be required here without
+  # minitest/autorun running its at_exit hook inside rake.
+  ENV["PRO_TACTS_CONTACTS_DIR"] = File.expand_path("test/fixtures/contacts", __dir__)
   require "pro_tacts/web"
   require_relative "test/pro_tacts/exchange_fixtures"
   ExchangeFixtures.record_responses(ProTacts::Web)
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 9781472..3d9804b 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -32,6 +32,12 @@ module ProTacts
       !value.nil? && value.match?(TRUTHY)
     end
 
+    # Directory of contact KDL files, one contact per file; the filename
+    # is the contact ID. See docs/plans/2026-01-12-carddav-architecture.md.
+    def contacts_dir
+      @env.fetch("PRO_TACTS_CONTACTS_DIR", "data/contacts")
+    end
+
     # Where the debug logger writes. A path, overridable with
     # PRO_TACTS_DEBUG_LOG; "stderr" keeps it on the process's stderr.
     def debug_log_path
diff --git a/lib/pro_tacts/contacts.rb b/lib/pro_tacts/contacts.rb
new file mode 100644
index 0000000..70c2e6d
--- /dev/null
+++ b/lib/pro_tacts/contacts.rb
@@ -0,0 +1,57 @@
+require "kdl"
+require "pathname"
+require "sentry-ruby"
+
+require "pro_tacts/vcard"
+
+module ProTacts
+  # Reads contacts from a directory of KDL files, one contact per file.
+  # The filename minus extension is the contact ID, which maps to the
+  # vCard UID (see docs/plans/2026-01-12-carddav-architecture.md).
+  class Contacts
+    Contact = Struct.new(:id, :vcard, keyword_init: true)
+
+    # IDs end up in paths, and they arrive from client-supplied hrefs, so
+    # anything outside this charset simply does not exist.
+    ID_FORMAT = /\A[\w-]+\z/
+
+    attr_reader :directory
+
+    def initialize(directory)
+      @directory = Pathname.new(directory)
+      unless @directory.directory?
+        raise ArgumentError, "contacts directory not found: #{@directory}"
+      end
+    end
+
+    def all
+      directory.glob("*.kdl").sort.map { load(it) }.compact
+    end
+
+    def find(id)
+      return nil unless id.match?(ID_FORMAT)
+
+      path = directory / "#{id}.kdl"
+      load(path) if path.file?
+    end
+
+    private
+
+    # Rendering at load time doubles as validation: a file whose card
+    # cannot render is reported and skipped rather than taking the whole
+    # address book down with it. Sentry is a no-op while uninitialized,
+    # so tests need no DSN.
+    def load(path)
+      id = path.basename(".kdl").to_s
+      nodes = KDL.parse(path.read).nodes
+      unless nodes.length == 1 && nodes.first.name == "contact"
+        raise ArgumentError, "expected exactly one contact node"
+      end
+
+      Contact.new(id:, vcard: VCard.render(nodes.first, uid: id))
+    rescue KDL::Error, ArgumentError, SystemCallError => e
+      Sentry.capture_message("skipping contact file #{path}: #{e.class}: #{e.message}")
+      nil
+    end
+  end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d8b4797..b237454 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -21,9 +21,11 @@ unless ProTacts.config.test?
 end
 
 require "rack/rewindable_input"
+require "nokogiri"
 require "roda"
 
 require "pro_tacts/debug_logger"
+require "pro_tacts/contacts"
 require "roda/plugins/dav_verbs"
 
 module ProTacts
@@ -46,6 +48,12 @@ module ProTacts
       "Not Found"
     end
 
+    # Placeholder until etags and ctags are derived from file state; the
+    # constants only need to be present and stable within a session.
+    CONTACT_ETAG = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
+    COLLECTION_CTAG = "ctag-2"
+    SYNC_TOKEN = "http://pro-tacts/sync/2"
+
     route do |r|
       r.is "" do
         r.propfind do
@@ -138,31 +146,16 @@ module ProTacts
             response["Content-Type"] = "text/xml"
             response.status = 207
 
-            contact_etag = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
-            collection_ctag = "ctag-2"
             depth = request.env.fetch("HTTP_DEPTH", "infinity")
 
             # Check if this is an etag-only request (Depth:1 listing)
             etag_only = body.include?("getetag") && !body.include?("displayname") && !body.include?("resourcetype")
 
-            if etag_only
-              # Etag-only ask wants the members; the collection self-entry
-              # is omitted until a client is found to need it.
-              collection_response = ""
-
-              contact_response = <<~XML
-                <d:response>
-                  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</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
-            else
-              # Full property request (Depth:0 collection info)
+            # Etag-only asks want the members; the collection self-entry
+            # is omitted until a client is found to need it. Full property
+            # requests (Depth:0 collection info) get the collection entry.
+            collection_response = ""
+            unless etag_only
               collection_response = <<~XML
                 <d:response>
                   <d:href>/dav/addressbook/</d:href>
@@ -177,20 +170,8 @@ module ProTacts
                           <d:report><d:sync-collection/></d:report>
                         </d:supported-report>
                       </d:supported-report-set>
-                      <cs:getctag>#{collection_ctag}</cs:getctag>
-                      <d:sync-token>http://pro-tacts/sync/2</d:sync-token>
-                    </d:prop>
-                    <d:status>HTTP/1.1 200 OK</d:status>
-                  </d:propstat>
-                </d:response>
-              XML
-
-              contact_response = <<~XML
-                <d:response>
-                  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
-                  <d:propstat>
-                    <d:prop>
-                      <d:getetag>#{contact_etag}</d:getetag>
+                      <cs:getctag>#{COLLECTION_CTAG}</cs:getctag>
+                      <d:sync-token>#{SYNC_TOKEN}</d:sync-token>
                     </d:prop>
                     <d:status>HTTP/1.1 200 OK</d:status>
                   </d:propstat>
@@ -199,7 +180,7 @@ module ProTacts
             end
 
             # Depth: 0 returns only collection, Depth: 1 includes members
-            members = depth == "0" ? "" : contact_response
+            members = depth == "0" ? "" : contacts.all.map { etag_response(it.id) }.join
 
             <<~XML
               <?xml version="1.0" encoding="UTF-8"?>
@@ -216,60 +197,106 @@ module ProTacts
 
             response["Content-Type"] = "text/xml"
             response.status = 207
-            contact_etag = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
 
-            if body.include?("sync-collection")
+            doc = Nokogiri::XML(body)
+            doc.remove_namespaces!
+
+            if doc.root.name == "sync-collection"
               # The warm-sync ask is etag-only; a changed etag sends the
               # client back through multiget, so no address-data here.
-              card_data = ""
+              responses = contacts.all.map { etag_response(it.id) }
             else
-              vcard = <<~VCARD.chomp
-                BEGIN:VCARD
-                VERSION:3.0
-                PRODID:-//Apple Inc.//macOS 14.6.1//EN
-                N:Contact;Test;;;
-                FN:Test Contact
-                REV:2026-01-14T00:00:00Z
-                UID:AB12C345-6789-0DEF-1234-567890ABCDEF
-                END:VCARD
-              VCARD
-              card_data = "<card:address-data>#{vcard}</card:address-data>"
+              wants_cards = doc.xpath("//address-data").any?
+
+              responses = doc.xpath("//href").map { it.text }.map { |requested|
+                id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
+                contact = id && contacts.find(id)
+
+                if contact
+                  wants_cards ? card_response(contact) : etag_response(contact.id)
+                else
+                  missing_response(requested)
+                end
+              }
             end
 
             <<~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>#{contact_etag}</d:getetag>
-                      #{card_data unless card_data.empty?}
-                    </d:prop>
-                    <d:status>HTTP/1.1 200 OK</d:status>
-                  </d:propstat>
-                </d:response>
+                #{responses.join}
               </d:multistatus>
             XML
           end
 
-          r.get String do |uid|
-            response["Content-Type"] = "text/vcard; charset=utf-8"
-            response["ETag"] = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
-
-            <<~VCARD.gsub(/^ +/, "")
-              BEGIN:VCARD
-              VERSION:3.0
-              PRODID:-//Apple Inc.//macOS 14.6.1//EN
-              N:Contact;Test;;;
-              FN:Test Contact
-              REV:2026-01-14T00:00:00Z
-              UID:AB12C345-6789-0DEF-1234-567890ABCDEF
-              END:VCARD
-            VCARD
+          r.get String do |filename|
+            contact = contacts.find(filename.delete_suffix(".vcf"))
+
+            # No match falls through to the empty-body 404 that the
+            # not_found handler fills in.
+            if contact
+              response["Content-Type"] = "text/vcard; charset=utf-8"
+              response["ETag"] = CONTACT_ETAG
+              contact.vcard
+            end
           end
         end
       end
     end
+
+    private
+
+    # Instantiated per request so tests can point it at a fixture
+    # directory through ProTacts.config=; caching belongs with real etags.
+    def contacts
+      @contacts ||= Contacts.new(ProTacts.config.contacts_dir)
+    end
+
+    def contact_href(id)
+      "/dav/addressbook/#{id}.vcf"
+    end
+
+    def etag_response(id)
+      <<~XML
+        <d:response>
+          <d:href>#{contact_href(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
+    end
+
+    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.chomp)}</card:address-data>
+            </d:prop>
+            <d:status>HTTP/1.1 200 OK</d:status>
+          </d:propstat>
+        </d:response>
+      XML
+    end
+
+    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 >.
+    def xml_escape(text)
+      text.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
+    end
   end
 end
diff --git a/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
new file mode 100644
index 0000000..e1a0409
--- /dev/null
+++ b/test/fixtures/contacts/AB12C345-6789-0DEF-1234-567890ABCDEF.kdl
@@ -0,0 +1,3 @@
+contact {
+    name "Test Contact"
+}
diff --git a/test/fixtures/macos-exchange/07-report-multiget/response b/test/fixtures/macos-exchange/07-report-multiget/response
index be34f5b..2528ee4 100644
--- a/test/fixtures/macos-exchange/07-report-multiget/response
+++ b/test/fixtures/macos-exchange/07-report-multiget/response
@@ -4,20 +4,19 @@ 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>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
-        <card:address-data>BEGIN:VCARD
-VERSION:3.0
-PRODID:-//Apple Inc.//macOS 14.6.1//EN
-N:Contact;Test;;;
-FN:Test Contact
-REV:2026-01-14T00:00:00Z
-UID:AB12C345-6789-0DEF-1234-567890ABCDEF
+  <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
+  <d:propstat>
+    <d:prop>
+      <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+      <card:address-data>BEGIN:VCARD
+VERSION:3.0
+N:Contact;Test;;;
+FN:Test Contact
+UID:AB12C345-6789-0DEF-1234-567890ABCDEF
 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/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index aa8852a..7ab90e2 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -4,13 +4,13 @@ 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>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</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>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</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_config.rb b/test/pro_tacts/test_config.rb
index d32215e..aaa0d52 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -25,6 +25,14 @@ class ConfigTest < Minitest::Test
     assert_raises(KeyError) { ProTacts::Config.new({}).sentry_dsn }
   end
 
+  def test_contacts_dir_defaults_to_data_contacts
+    assert_equal "data/contacts", ProTacts::Config.new({}).contacts_dir
+  end
+
+  def test_contacts_dir_is_overridable
+    assert_equal "/tmp/kdl", ProTacts::Config.new("PRO_TACTS_CONTACTS_DIR" => "/tmp/kdl").contacts_dir
+  end
+
   def test_debug_defaults_off
     refute ProTacts::Config.new({}).debug?
     refute ProTacts::Config.new("PRO_TACTS_DEBUG" => nil).debug?
diff --git a/test/pro_tacts/test_contacts.rb b/test/pro_tacts/test_contacts.rb
new file mode 100644
index 0000000..ab32c9e
--- /dev/null
+++ b/test/pro_tacts/test_contacts.rb
@@ -0,0 +1,97 @@
+require_relative "../test_helper"
+
+require "tmpdir"
+
+require "pro_tacts/contacts"
+
+class ContactsTest < Minitest::Test
+  def with_contacts(files)
+    Dir.mktmpdir do |dir|
+      directory = Pathname.new(dir)
+      files.each { |name, content| (directory / name).write(content) }
+      yield ProTacts::Contacts.new(directory)
+    end
+  end
+
+  def test_all_lists_contacts_sorted_by_id
+    with_contacts({
+      "znorth.kdl" => "contact { name \"Zed\" }",
+      "aiden.kdl" => "contact { name \"Aiden\" }",
+    }) do |contacts|
+      assert_equal %w[aiden znorth], contacts.all.map { it.id }
+    end
+  end
+
+  def test_find_returns_the_contact_by_id
+    with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+      contact = contacts.find("aiden")
+
+      assert_equal "aiden", contact.id
+      assert_includes contact.vcard, "FN:Aiden"
+    end
+  end
+
+  def test_the_uid_comes_from_the_filename
+    with_contacts({"kqmtnwpxlrvszoyp.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+      assert_includes contacts.find("kqmtnwpxlrvszoyp").vcard, "UID:kqmtnwpxlrvszoyp"
+    end
+  end
+
+  def test_find_returns_nil_for_unknown_ids
+    with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do |contacts|
+      assert_nil contacts.find("nope")
+    end
+  end
+
+  def test_find_rejects_ids_outside_the_charset
+    with_contacts({}) do |contacts|
+      assert_nil contacts.find("../secrets")
+      assert_nil contacts.find("a/b")
+      assert_nil contacts.find("a.vcf")
+    end
+  end
+
+  def test_a_missing_directory_raises
+    error = assert_raises(ArgumentError) do
+      ProTacts::Contacts.new(Pathname.new(Dir.mktmpdir) / "nonexistent")
+    end
+
+    assert_match(/contacts directory not found/, error.message)
+  end
+
+  def test_an_empty_directory_lists_no_contacts
+    with_contacts({}) do |contacts|
+      assert_empty contacts.all
+    end
+  end
+
+  def test_unparseable_files_are_skipped
+    with_contacts({
+      "broken.kdl" => "contact {",
+      "aiden.kdl" => "contact { name \"Aiden\" }",
+    }) do |contacts|
+      assert_equal %w[aiden], contacts.all.map { it.id }
+      assert_nil contacts.find("broken")
+    end
+  end
+
+  def test_files_without_exactly_one_contact_node_are_skipped
+    with_contacts({
+      "empty.kdl" => "",
+      "two.kdl" => "contact { name \"A\" }\ncontact { name \"B\" }",
+      "other.kdl" => "person { name \"A\" }",
+    }) do |contacts|
+      assert_empty contacts.all
+    end
+  end
+
+  def test_files_whose_card_cannot_render_are_skipped
+    with_contacts({
+      "nameless.kdl" => "contact { phone \"+1-555-1234\" }",
+      "aiden.kdl" => "contact { name \"Aiden\" }",
+    }) do |contacts|
+      assert_equal %w[aiden], contacts.all.map { it.id }
+      assert_nil contacts.find("nameless")
+    end
+  end
+end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index f61aabf..70c6c61 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1,7 +1,9 @@
 
 require_relative "../test_helper"
 require "rack/test"
+require "tmpdir"
 
+require "pro_tacts/config"
 require "pro_tacts/web"
 
 class WebTest < Minitest::Test
@@ -96,4 +98,116 @@ class WebTest < Minitest::Test
     assert_includes last_response.body, "getctag"
     assert_includes last_response.body, "AB12C345-6789-0DEF-1234-567890ABCDEF.vcf"
   end
+
+  def test_get_unknown_contact_is_404
+    get "/dav/addressbook/nope.vcf"
+
+    assert_equal 404, last_response.status
+    assert_equal "Not Found", last_response.body
+  end
+
+  # Swaps in a throwaway contacts directory so the multi-contact routes
+  # can be exercised without touching the exchange fixture data.
+  def with_contacts(files)
+    Dir.mktmpdir do |dir|
+      files.each { |name, content| File.write(File.join(dir, name), content) }
+      original = ProTacts.config
+      ProTacts.config = ProTacts::Config.new({
+        "RACK_ENV" => "test",
+        "PRO_TACTS_CONTACTS_DIR" => dir,
+      })
+      begin
+        yield
+      ensure
+        ProTacts.config = original
+      end
+    end
+  end
+
+  def etag_only_propfind
+    <<~XML
+      <?xml version="1.0" encoding="UTF-8"?>
+      <A:propfind xmlns:A="DAV:">
+        <A:prop>
+          <A:getetag/>
+        </A:prop>
+      </A:propfind>
+    XML
+  end
+
+  def multiget(*ids)
+    hrefs = ids.map { "<A:href xmlns:A=\"DAV:\">/dav/addressbook/#{it}.vcf</A:href>" }.join("\n    ")
+
+    <<~XML
+      <?xml version="1.0" encoding="UTF-8"?>
+      <C:addressbook-multiget xmlns:C="urn:ietf:params:xml:ns:carddav">
+        <A:prop xmlns:A="DAV:">
+          <A:getetag/>
+          <C:address-data/>
+        </A:prop>
+        #{hrefs}
+      </C:addressbook-multiget>
+    XML
+  end
+
+  def test_listing_and_multiget_serve_every_contact_on_disk
+    with_contacts({
+      "aiden.kdl" => "contact { name \"Aiden\" }",
+      "znorth.kdl" => "contact { name \"Zed\" }",
+    }) do
+      request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+
+      assert_equal 207, last_response.status
+      assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
+      assert_includes last_response.body, "/dav/addressbook/znorth.vcf"
+
+      request "/dav/addressbook/", method: "REPORT", input: multiget("aiden", "znorth")
+
+      assert_equal 207, last_response.status
+      assert_includes last_response.body, "FN:Aiden"
+      assert_includes last_response.body, "UID:aiden"
+      assert_includes last_response.body, "FN:Zed"
+      assert_includes last_response.body, "UID:znorth"
+    end
+  end
+
+  def test_multiget_reports_unknown_hrefs_as_404
+    with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+      request "/dav/addressbook/", method: "REPORT", input: multiget("aiden", "nope")
+
+      assert_equal 207, last_response.status
+      assert_includes last_response.body, "FN:Aiden"
+      assert_includes last_response.body, "/dav/addressbook/nope.vcf"
+      assert_includes last_response.body, "HTTP/1.1 404 Not Found"
+    end
+  end
+
+  def test_multiget_escapes_vcard_content_for_xml
+    with_contacts({"aiden.kdl" => "contact { name \"A & B <Team>\" }"}) do
+      request "/dav/addressbook/", method: "REPORT", input: multiget("aiden")
+
+      assert_equal 207, last_response.status
+      assert_includes last_response.body, "FN:A &amp; B &lt;Team&gt;"
+    end
+  end
+
+  def test_sync_collection_returns_etags_only
+    with_contacts({"aiden.kdl" => "contact { name \"Aiden\" }"}) do
+      request "/dav/addressbook/", method: "REPORT", input: <<~XML
+        <?xml version="1.0" encoding="UTF-8"?>
+        <A:sync-collection xmlns:A="DAV:">
+          <A:sync-token>http://pro-tacts/sync/1</A:sync-token>
+          <A:sync-level>1</A:sync-level>
+          <A:prop>
+            <A:getetag/>
+          </A:prop>
+        </A:sync-collection>
+      XML
+
+      assert_equal 207, last_response.status
+      assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
+      assert_includes last_response.body, "getetag"
+      refute_includes last_response.body, "address-data"
+    end
+  end
 end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 81d6508..3606673 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,6 +1,9 @@
 
 # Marks the process as running tests before anything requires the app, so
-# web.rb skips Sentry.init and no SENTRY_DSN is needed.
+# web.rb skips Sentry.init and no SENTRY_DSN is needed. The contacts
+# directory holds the card the recorded macOS exchange asked for, so the
+# fixture replay resolves the same hrefs the client did.
 ENV["RACK_ENV"] = "test"
+ENV["PRO_TACTS_CONTACTS_DIR"] = File.expand_path("fixtures/contacts", __dir__)
 
 require "minitest/autorun"