Derive etags and ctags from file state
Mtime is the obvious key and the wrong one: git rewrites it on every
checkout, and a reformat-only edit changes a file's bytes without
changing the card. Hashing the rendered vCard moves the etag exactly
when what the client downloads moves.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/README.md b/README.md
index 262b469..05dd052 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,10 @@ 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.
+established. Etags, the ctag, and the sync token are derived from file
+state — a contact's etag hashes its rendered vCard, and the collection
+tags hash the membership — so an edit on disk reaches synced clients on
+their next poll. There is no authentication yet.
## The minimal set macOS Contacts needs
diff --git a/lib/pro_tacts/addressbook.rb b/lib/pro_tacts/addressbook.rb
new file mode 100644
index 0000000..51e11be
--- /dev/null
+++ b/lib/pro_tacts/addressbook.rb
@@ -0,0 +1,30 @@
+require "digest"
+
+require "pro_tacts/contact"
+
+module ProTacts
+ # The address book collection: every contact under the contacts
+ # directory, plus the collection-wide state derived from those members.
+ # The ctag changes when any card is added, removed, or changed and
+ # nothing else, so a client comparing two of them learns whether a
+ # resync is needed — never what changed.
+ class Addressbook < Data.define(:contacts)
+ def self.load(directory)
+ new(contacts: Contact.all(directory))
+ end
+
+ # Sorting the id-and-etag lines makes the value independent of
+ # directory listing order while staying sensitive to membership and
+ # content.
+ def ctag
+ Digest::SHA256.hexdigest(contacts.map { "#{it.id} #{it.etag}" }.sort.join("\n"))
+ end
+
+ # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
+ # form is conventional. Built on the ctag so a client polling either
+ # one sees changes at the same points.
+ def sync_token
+ "http://pro-tacts/sync/#{ctag}"
+ end
+ end
+end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9f47cbf..29e0c1a 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -1,3 +1,4 @@
+require "digest"
require "kdl"
require "pathname"
@@ -7,7 +8,14 @@ module ProTacts
# A contact parsed from one KDL file under the contacts directory; the
# file is a bare KDL document, and the filename is the id, which maps
# to the vCard UID (see docs/plans/2026-01-12-carddav-architecture.md).
- class Contact < Data.define(:id, :vcard)
+ #
+ # The etag hashes the rendered vCard rather than the file's bytes or
+ # mtime, so it changes exactly when what the client downloads changes:
+ # git rewrites mtimes on every checkout, and a reformat-only edit
+ # changes the bytes without changing the card. It is stored in the
+ # entity-tag's quoted form (RFC 7232 section 2.3), which is what both
+ # the ETag header and getetag properties carry.
+ class Contact < Data.define(:id, :vcard, :etag)
# Ids end up in paths and arrive from client-supplied hrefs, so a
# filename outside this charset cannot be served.
ID_FORMAT = /\A[\w-]+\z/
@@ -31,7 +39,8 @@ module ProTacts
id = path.basename(".kdl").to_s
raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
- new(id:, vcard: VCard.render(KDL.parse(path.read), uid: id))
+ vcard = VCard.render(KDL.parse(path.read), uid: id)
+ new(id:, vcard:, etag: %("#{Digest::SHA256.hexdigest(vcard)}"))
end
end
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 1a22944..aa4d75e 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -7,7 +7,7 @@ require "nokogiri"
require "roda"
require "pro_tacts/debug_logger"
-require "pro_tacts/contact"
+require "pro_tacts/addressbook"
require "roda/plugins/dav_verbs"
module ProTacts
@@ -29,12 +29,6 @@ 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
@@ -151,8 +145,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>#{SYNC_TOKEN}</d:sync-token>
+ <cs:getctag>#{addressbook.ctag}</cs:getctag>
+ <d:sync-token>#{addressbook.sync_token}</d:sync-token>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -161,7 +155,7 @@ module ProTacts
end
# Depth: 0 returns only collection, Depth: 1 includes members
- members = depth == "0" ? "" : contacts.map { etag_response(it.id) }.join
+ members = depth == "0" ? "" : addressbook.contacts.map { etag_response(it) }.join
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
@@ -185,16 +179,16 @@ module ProTacts
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.
- responses = contacts.map { etag_response(it.id) }
+ responses = addressbook.contacts.map { etag_response(it) }
else
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 { it.id == id }
+ contact = id && addressbook.contacts.find { it.id == id }
if contact
- wants_cards ? card_response(contact) : etag_response(contact.id)
+ wants_cards ? card_response(contact) : etag_response(contact)
else
missing_response(requested)
end
@@ -210,13 +204,13 @@ module ProTacts
end
r.get String do |filename|
- contact = contacts.find { it.id == filename.delete_suffix(".vcf") }
+ contact = addressbook.contacts.find { it.id == 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
+ response["ETag"] = contact.etag
contact.vcard
end
end
@@ -226,24 +220,25 @@ module ProTacts
private
- # Parsed once per request — Roda builds a fresh app instance for each
- # one — with the directory coming from config. Caching belongs with
- # real etags.
- def contacts
- @contacts ||= Contact.all(ProTacts.config.contacts_dir)
+ # Loaded once per request — Roda builds a fresh app instance for each
+ # one — with the directory coming from config. Real etags and ctags
+ # make an mtime-keyed cache possible, but re-parsing a family address
+ # book per request is cheap and can never serve a stale change tag.
+ def addressbook
+ @addressbook ||= Addressbook.load(ProTacts.config.contacts_dir)
end
def contact_href(id)
"/dav/addressbook/#{id}.vcf"
end
- def etag_response(id)
+ def etag_response(contact)
<<~XML
<d:response>
- <d:href>#{contact_href(id)}</d:href>
+ <d:href>#{contact_href(contact.id)}</d:href>
<d:propstat>
<d:prop>
- <d:getetag>#{CONTACT_ETAG}</d:getetag>
+ <d:getetag>#{contact.etag}</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -257,7 +252,7 @@ module ProTacts
<d:href>#{contact_href(contact.id)}</d:href>
<d:propstat>
<d:prop>
- <d:getetag>#{CONTACT_ETAG}</d:getetag>
+ <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>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 0d4b71c..e0a0a55 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
<d:report><d:sync-collection/></d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>ctag-2</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/2</d:sync-token>
+ <cs:getctag>cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</d:sync-token>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 91d0b4f..cd9cb4a 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -8,7 +8,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/07-report-multiget/response b/test/fixtures/macos-exchange/07-report-multiget/response
index 2528ee4..a023f27 100644
--- a/test/fixtures/macos-exchange/07-report-multiget/response
+++ b/test/fixtures/macos-exchange/07-report-multiget/response
@@ -7,7 +7,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
<card:address-data>BEGIN:VCARD
VERSION:3.0
N:Contact;Test;;;
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index 7ab90e2..e7b9bc8 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -7,7 +7,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index e1bcf12..9be0034 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
<d:report><d:sync-collection/></d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>ctag-2</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/2</d:sync-token>
+ <cs:getctag>cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</d:sync-token>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -27,7 +27,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+ <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/pro_tacts/test_addressbook.rb b/test/pro_tacts/test_addressbook.rb
new file mode 100644
index 0000000..12a9c80
--- /dev/null
+++ b/test/pro_tacts/test_addressbook.rb
@@ -0,0 +1,58 @@
+require_relative "../test_helper"
+
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/addressbook"
+
+class AddressbookTest < Minitest::Test
+ def with_contacts(files)
+ Dir.mktmpdir do |dir|
+ contacts_dir = Pathname.new(dir) / "contacts"
+ Dir.mkdir(contacts_dir)
+ files.each { |name, content| File.write(contacts_dir / name, content) }
+ yield contacts_dir
+ end
+ end
+
+ def ctag(contacts_dir)
+ ProTacts::Addressbook.load(contacts_dir).ctag
+ end
+
+ def test_the_ctag_is_stable_across_loads
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+ assert_equal ctag(contacts_dir), ctag(contacts_dir)
+ end
+ end
+
+ def test_the_ctag_moves_with_a_cards_content
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+ before = ctag(contacts_dir)
+ File.write(contacts_dir / "aiden.kdl", "name \"Aiden Smith\"")
+
+ refute_equal before, ctag(contacts_dir)
+ end
+ end
+
+ def test_the_ctag_moves_with_membership_both_ways
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+ alone = ctag(contacts_dir)
+
+ File.write(contacts_dir / "znorth.kdl", "name \"Zed\"")
+ refute_equal alone, ctag(contacts_dir)
+
+ File.delete(contacts_dir / "znorth.kdl")
+ assert_equal alone, ctag(contacts_dir)
+ end
+ end
+
+ def test_the_sync_token_moves_with_the_ctag
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+ token = ProTacts::Addressbook.load(contacts_dir).sync_token
+
+ File.write(contacts_dir / "aiden.kdl", "name \"Aiden Smith\"")
+
+ refute_equal token, ProTacts::Addressbook.load(contacts_dir).sync_token
+ end
+ end
+end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index bca93a5..50486a9 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -1,5 +1,6 @@
require_relative "../test_helper"
+require "digest"
require "pathname"
require "tmpdir"
@@ -32,6 +33,24 @@ class ContactTest < Minitest::Test
end
end
+ def test_the_etag_hashes_the_rendered_card
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |directory|
+ contact = ProTacts::Contact.all(directory).first
+
+ assert_equal %("#{Digest::SHA256.hexdigest(contact.vcard)}"), contact.etag
+ end
+ end
+
+ def test_the_etag_is_stable_across_parses_and_moves_with_content
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |directory|
+ etag = ProTacts::Contact.all(directory).first.etag
+ assert_equal etag, ProTacts::Contact.all(directory).first.etag
+
+ File.write(directory / "aiden.kdl", "name \"Aiden Smith\"")
+ refute_equal etag, ProTacts::Contact.all(directory).first.etag
+ end
+ end
+
def test_an_empty_directory_lists_no_contacts
with_contacts({}) do |directory|
assert_empty ProTacts::Contact.all(directory)
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 7fc855c..c8185ce 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1,5 +1,6 @@
require_relative "../test_helper"
+require "digest"
require "pathname"
require "rack/test"
require "tmpdir"
@@ -71,7 +72,7 @@ class WebTest < Minitest::Test
assert_equal 200, last_response.status
assert_equal "text/vcard; charset=utf-8", last_response["Content-Type"]
- assert_equal %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), last_response["ETag"]
+ assert_equal %("#{Digest::SHA256.hexdigest(last_response.body)}"), last_response["ETag"]
assert_includes last_response.body, "BEGIN:VCARD"
assert_includes last_response.body, "END:VCARD"
end
@@ -120,7 +121,7 @@ class WebTest < Minitest::Test
"PRO_TACTS_DATA_DIR" => dir,
})
begin
- yield
+ yield contacts_dir
ensure
ProTacts.config = original
end
@@ -213,4 +214,36 @@ class WebTest < Minitest::Test
refute_includes last_response.body, "address-data"
end
end
+
+ def test_etags_agree_across_listing_multiget_and_get
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do
+ request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+ etag = last_response.body[%r{<d:getetag>(.+)</d:getetag>}, 1]
+
+ get "/dav/addressbook/aiden.vcf"
+ assert_equal etag, last_response["ETag"]
+
+ request "/dav/addressbook/", method: "REPORT", input: multiget("aiden")
+ assert_includes last_response.body, "<d:getetag>#{etag}</d:getetag>"
+ end
+ end
+
+ def test_a_changed_card_changes_its_etag_and_the_collection_tags
+ with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+ read_tags = lambda {
+ request "/dav/addressbook/", method: "PROPFIND"
+ [last_response.body[%r{<d:getetag>(.+)</d:getetag>}, 1],
+ last_response.body[%r{<cs:getctag>(.+)</cs:getctag>}, 1],
+ last_response.body[%r{<d:sync-token>(.+)</d:sync-token>}, 1]]
+ }
+
+ before = read_tags.call
+ assert_equal before, read_tags.call # stable across requests
+
+ File.write(contacts_dir / "aiden.kdl", "name \"Aiden Smith\"")
+ after = read_tags.call
+
+ before.zip(after).each { refute_equal it.first, it.last }
+ end
+ end
end