photos: prove the round trip and document the wire shape
Both captured shapes (log/unhandled, 34 KB and 338 KB) already parse
and round-trip byte for byte, so nothing in lib changes — the tests
are the proof. The captures stay unpromoted because they are possibly
real images; test/photo_card.rb rebuilds their shapes with synthetic
payloads.
Assisted-by: GLM-5.3 via pi
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index 6b3d70e..b0a8dc3 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -187,6 +187,52 @@ The practical consequence is that any comparison between a card the server
sent and the card that comes back has to be semantic. Comparing bytes
reports every untouched property as modified. Verified 2026-08-24.
+## Profile pictures
+
+A contact with a picture carries it in the card, inline — `PHOTO` with
+`ENCODING=b;TYPE=JPEG` and a base64 payload (RFC 2426 section 3.1.4's
+second form), never a URI. A memoji card runs 34 KB, a real photo 338 KB
+— 323,900 octets of base64 for a 243 KB JPEG — because the client does
+not downscale before sending. A bare card is ~500 bytes, so photos are
+the sizing case; a memoji is not.
+
+The picture trio as it arrives:
+
+```
+X-IMAGEHASH:RKZaNYCmuYERD3AfbLtrIw==
+X-IMAGETYPE:PHOTO
+PHOTO;X-ABCROP-RECTANGLE=ABClipRect_1&38&0&769&768&RKZaNYCmuYERD3AfbLtrIw==;ENCODING=b;TYPE=JPEG:
+ <76 octets of base64>
+ ...
+```
+
+- `X-IMAGETYPE:PHOTO` sits directly above `PHOTO`, and the token
+ `X-ABCROP-RECTANGLE` ends with is `X-IMAGEHASH`'s value.
+- The crop rectangle can be a real crop — the photo's origin is (38,0),
+ not the whole image.
+- A memoji adds `VND-63-MEMOJI-DETAILS` — a base64 binary plist —
+ between the crop and the encoding, which alone runs the parameter
+ section to 1,683 octets on one physical line.
+
+RFC 2426 section 2.6's 75-octet fold is a SHOULD the client meets only
+after the colon: the payload is folded — a space, then 76 octets a
+continuation, 4,378 continuation lines for the photo — while the
+parameter section is not folded at all. A parser that assumes a
+folded-line ceiling, or that splits parameters before unfolding, breaks
+on the memoji card.
+
+Where the bytes land: warm sync asks only for etags, so polling stays
+~4 KB whatever the book holds. A cold sync — the etag listing plus one
+multiget with a bare `<C:address-data/>`, no property filter — ships
+every card whole, so one photo card makes that one response ~340 KB.
+Serving one contact, by GET or multiget, is the card's own size.
+Measured 2026-09-04 against a 13-contact book with one photo card:
+the multiget builds in ~2 ms and the PUT lands in ~17 ms, so the
+response size is the whole of the cost — the bytes are the floor, since
+the client needs the picture. Captured 2026-08-25, macOS 26.5.1
+(AddressBookCore/2732.600.11); the shapes are built synthetically in
+test/photo_card.rb, the captures being possibly real images.
+
## Birthdays without a year
A birthday entered as a month and day carries Apple's own parameter, using
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 8500f86..6b6914d 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -167,10 +167,13 @@ module ProTacts
# The recorded macOS session folds nothing: it sent a 443-octet
# X-ADDRESSING-GRAMMAR line and an 81-octet ADR unbroken, both
# past the 75-octet limit, with no continuation in the card
- # (test/fixtures/macos-exchange/10-put-contact-edit). That is one
- # capture of one card, and the PHOTO bodies that would settle it
- # went unpromoted, so this stays general rather than becoming
- # another macOS assumption.
+ # (test/fixtures/macos-exchange/10-put-contact-edit). The PHOTO
+ # bodies that would settle the other half stayed unpromoted
+ # (test/photo_card.rb builds their shapes) and they settle it
+ # both ways: a picture's payload arrives folded, a space then 76
+ # octets a continuation, while its parameter section arrives not
+ # folded at all — 1,683 octets on one line for a memoji. A whole-
+ # line unfold reads both without caring which it was given.
#: (String line) -> String
def self.unfold(line)
line.gsub(FOLD, "")
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
index bec4442..d727c98 100644
--- a/test/fixtures/macos-exchange/README.md
+++ b/test/fixtures/macos-exchange/README.md
@@ -49,3 +49,6 @@ The capture also lost the body's final newline — 835 bytes declared,
834 recorded — so the promoted card ends at `END:VCARD`. Captures with
`PHOTO` bodies (34 KB and 338 KB cards) were left unpromoted: possibly
real images, and the same code path as the 834-byte card that was.
+Their wire shapes — the unfolded parameter section, the payload folded
+at 76 octets a continuation — are built synthetically in
+`test/photo_card.rb` and documented in `docs/macos-contacts.md`.
diff --git a/test/photo_card.rb b/test/photo_card.rb
new file mode 100644
index 0000000..4502f3d
--- /dev/null
+++ b/test/photo_card.rb
@@ -0,0 +1,124 @@
+require "base64"
+
+require "pro_tacts/vcard"
+
+# Builds the cards macOS Contacts sends for a contact with a picture,
+# in the exact wire shapes of the two captures in log/unhandled
+# (put-...-1a6555e1 for the memoji, put-...-ae05fe03 for the photo).
+# The captures stay unpromoted — possibly real images, see
+# test/fixtures/macos-exchange/README.md — so the shapes live here as
+# structure with synthetic payloads standing in for the pictures.
+#
+# What the captures carry, and this reproduces:
+#
+# - PHOTO's parameter section is one physical line ending at the
+# colon, unfolded however long it is: 99 octets for a photo, 1,685
+# for a memoji, whose VND-63-MEMOJI-DETAILS parameter (a base64
+# binary plist) is most of those octets. RFC 2426 section 2.6's
+# 75-octet limit is a SHOULD the client meets only after the colon.
+# - The base64 payload is inline (ENCODING=b, TYPE=JPEG — never a
+# URI), folded one break below the parameters: every continuation is
+# a single space then 76 octets of payload.
+# - X-IMAGETYPE:PHOTO sits directly above PHOTO, and the token
+# X-ABCROP-RECTANGLE ends with is X-IMAGEHASH's value.
+#
+# The cards land within a percent of the captures' own sizes; the two
+# dimensions that define the shapes — the parameter lines' 97 and
+# 1,683 octets — match exactly.
+module PhotoCard
+ # The token naming the image in both X-IMAGEHASH and the crop
+ # rectangle's tail, matching the captures' shape: 24 octets ending
+ # "==", the encoding of a 16-byte hash.
+ IMAGE_HASH = Base64.strict_encode64("pro-tacts-image!") #: String
+
+ # A photo's parameters (capture ae05fe03): a real crop — origin
+ # (38,0), not the whole image — then the encoding pair.
+ PHOTO_PARAMETERS = [
+ "X-ABCROP-RECTANGLE=ABClipRect_1&38&0&769&768&#{IMAGE_HASH}",
+ "ENCODING=b",
+ "TYPE=JPEG",
+ ] #: Array[String]
+
+ # A memoji's parameters (capture 1a6555e1): the crop is the whole
+ # image, and VND-63-MEMOJI-DETAILS sits between it and the encoding
+ # pair. The capture's whole parameter section runs 1,683 octets on
+ # one physical line, so the value is sized to land this on that
+ # number — past 75 by an order of magnitude, which is the dimension
+ # that matters, at the capture's own mark. The capture's own value
+ # is a base64 binary plist; synthetic base64 stands in, since
+ # nothing on the server decodes it.
+ MEMOJI_LINE = 1_683 #: Integer
+ MEMOJI_CROP = "X-ABCROP-RECTANGLE=ABClipRect_1&0&0&420&420&#{IMAGE_HASH}" #: String
+ MEMOJI_DETAILS_BYTES =
+ (MEMOJI_LINE - "PHOTO;#{MEMOJI_CROP};VND-63-MEMOJI-DETAILS=".length - ";ENCODING=b;TYPE=JPEG:".length)
+ .then { it - it % 4 } # base64 characters come four to three bytes
+ .then { it / 4 * 3 } #: Integer
+ MEMOJI_PARAMETERS = [
+ MEMOJI_CROP,
+ "VND-63-MEMOJI-DETAILS=#{Base64.strict_encode64("m" * MEMOJI_DETAILS_BYTES)}",
+ "ENCODING=b",
+ "TYPE=JPEG",
+ ] #: Array[String]
+
+ # The photo property as the client folds it, per the module comment:
+ # parameters unbroken on one line, payload a space then 76 octets a
+ # continuation. Deliberately not VCard.fold, which honors the
+ # 75-octet limit the client ignores here — this half builds what
+ # arrives, and the writer's half is VCard's.
+ #: (Array[String] parameters, String image_bytes) -> String
+ def self.property(parameters, image_bytes)
+ base64 = Base64.strict_encode64(image_bytes)
+ continuations = base64.scan(/.{1,76}/).map { " #{it}" }
+ "PHOTO;#{parameters.join(';')}:\r\n#{continuations.join("\r\n")}\r\n"
+ end
+
+ # Synthetic image bytes: never decoded — a card's payload is stored
+ # and served as the base64 text it arrives as — so a repeating
+ # pattern stands in, deterministic so a fixture's etag does not vary
+ # per run.
+ #: (Integer bytes) -> String
+ def self.image(bytes)
+ ("\xFF\xD8\xFF\xE0" + "synthetic photo payload" * (bytes / 22 + 1)).byteslice(0, bytes)
+ end
+
+ # A card with a real photo attached: the 338 KB capture's shape. A
+ # 242,925-byte image is the capture's size, because the client does
+ # not downscale before sending — photos are the sizing case.
+ #: (String id, ?bytes: Integer, ?extra: Array[String]) -> String
+ def self.photo(id, bytes: 242_925, extra: [])
+ card(id, property(PHOTO_PARAMETERS, image(bytes)), extra:)
+ end
+
+ # A card with a memoji attached: the 34 KB capture's shape, whose
+ # parameter section runs 1,685 octets unfolded on one line.
+ #: (String id, ?extra: Array[String]) -> String
+ def self.memoji(id, extra: [])
+ card(id, property(MEMOJI_PARAMETERS, image(23 * 1024)), extra:)
+ end
+
+ # The card around the picture trio, the capture's own lines: the
+ # trio sits between NOTE and REV, and `extra` lands above it, which
+ # is where the captures carry their BDAY.
+ #: (String id, String photo_property, extra: Array[String]) -> String
+ def self.card(id, photo_property, extra: [])
+ [
+ "BEGIN:VCARD",
+ "VERSION:3.0",
+ "PRODID:-//Apple Inc.//macOS 26.5.1//EN",
+ "N:Lovelace;Ada;;;",
+ "FN:Ada Lovelace",
+ "EMAIL;type=INTERNET;type=pref:ada@example.com",
+ "TEL;type=CELL;type=VOICE;type=pref:+1-555-0100",
+ "X-ADDRESSING-GRAMMAR;type=pref:#{Base64.strict_encode64("addressing-grammar")}",
+ "ADR;type=HOME;type=pref:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+ "NOTE:Test",
+ *extra,
+ "X-IMAGEHASH:#{IMAGE_HASH}",
+ "X-IMAGETYPE:PHOTO",
+ photo_property,
+ "REV:2026-08-25T03:15:06Z",
+ "UID:#{id}",
+ "END:VCARD",
+ ].join("\r\n") + "\r\n"
+ end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index a3fb877..6a6f562 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1,4 +1,5 @@
require_relative "../test_helper"
+require_relative "../photo_card"
require "pathname"
require "tmpdir"
@@ -12,6 +13,9 @@ class StoreTest < Minitest::Test
include SentryMessages
AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+ # The no-year birthday line as macOS writes it — the shape the
+ # captured picture cards carry above their PHOTO trio.
+ APPLE_NO_YEAR = "BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01" #: String
# UTC ISO 8601 to the millisecond, which is what SQLite is asked for.
TIMESTAMP = /\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\z/
ZED = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zed\r\nUID:znorth\r\nEND:VCARD\r\n"
@@ -61,6 +65,43 @@ class StoreTest < Minitest::Test
with_store { assert_nil it.contact("nobody") }
end
+ ## Pictures
+
+ # The sizing case, byte for byte: a photo card is 338 KB — most of
+ # it the PHOTO property's base64 folds — and the store's verbatim
+ # design means every fold comes back out exactly as it arrived. The
+ # memoji shape exercises the one physical line that runs 1,683
+ # octets.
+ def test_a_photo_card_round_trips_byte_for_byte
+ with_store({}) do |store|
+ store.put("ada", PhotoCard.photo("ada"))
+
+ assert_equal PhotoCard.photo("ada"), store.contact("ada").vcard.to_s
+ end
+ end
+
+ def test_a_memoji_card_round_trips_byte_for_byte
+ with_store({}) do |store|
+ store.put("ada", PhotoCard.memoji("ada"))
+
+ assert_equal PhotoCard.memoji("ada"), store.contact("ada").vcard.to_s
+ end
+ end
+
+ # The captured writes carry their BDAY above the picture trio, so
+ # the birthday's move to the model and back is the one difference a
+ # picture card sees: the PHOTO line, folds and all, is untouched.
+ def test_a_photo_card_moves_only_its_birthday
+ submitted = PhotoCard.photo("ada", extra: [APPLE_NO_YEAR])
+ expected = PhotoCard.photo("ada").sub("END:VCARD\r\n", "#{APPLE_NO_YEAR}\r\nEND:VCARD\r\n")
+
+ with_store({}) do |store|
+ store.put("ada", submitted)
+
+ assert_equal expected, store.contact("ada").vcard.to_s
+ end
+ end
+
## UIDs
# The read behind the no-uid-conflict precondition (RFC 6352 section
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 7bc5624..af5ffba 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1,5 +1,7 @@
require_relative "../test_helper"
+require_relative "../photo_card"
+
require "digest"
require "fileutils"
require "pathname"
@@ -515,6 +517,66 @@ class WebTest < Minitest::Test
end
end
+ ## Pictures
+
+ # The sizing case on the wire: a 338 KB PUT — the client does not
+ # downscale before sending — stores octet for octet, so the answer
+ # carries the strong etag of exactly those bytes (RFC 6352 section
+ # 6.3.2.3) and the GET hands them back unchanged.
+ def test_put_of_a_photo_card_round_trips_octet_identically
+ card = PhotoCard.photo("new")
+
+ with_contacts({}) do
+ put_request "new", card, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ assert_equal 201, last_response.status
+ assert_equal %("#{Digest::SHA256.hexdigest(card)}"), last_response["ETag"]
+
+ get "/dav/addressbook/new.vcf"
+
+ assert_equal card, last_response.body
+ assert_equal %("#{Digest::SHA256.hexdigest(card)}"), last_response["ETag"]
+ end
+ end
+
+ # A bare <C:address-data/> with no property filter is what macOS
+ # asks a multiget with (fixture 07-report-multiget), so a picture
+ # ships whole inside it — where the sizing case's cost lands.
+ def test_a_multiget_ships_a_photo_card_whole
+ card = PhotoCard.photo("new")
+
+ with_contacts({}) do
+ put_request "new", card, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ 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)
+ end
+ end
+
+ # The captured write carries its BDAY above the picture trio, so the
+ # birthday's move to the model and back is the one difference the
+ # card sees: the PUT goes without the strong etag — the client
+ # refetches — and the refetch carries the picture and the birthday
+ # both.
+ def test_put_of_a_photo_card_with_a_birthday_moves_only_the_birthday
+ submitted = PhotoCard.photo("new", extra: ["BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01"])
+ expected = PhotoCard.photo("new").sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01\r\nEND:VCARD\r\n")
+
+ with_contacts({}) do
+ put_request "new", submitted, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
+
+ assert_equal 201, last_response.status
+ assert_nil last_response["ETag"]
+
+ get "/dav/addressbook/new.vcf"
+
+ assert_equal expected, last_response.body
+ end
+ end
+
# A body arrives flagged ASCII-8BIT — Rack's rule for request input —
# and becomes UTF-8 where the route meets the wire, so a card with
# non-ASCII in it stores and serves as the UTF-8 it is.
@@ -550,6 +612,12 @@ class WebTest < Minitest::Test
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\nUID:#{id}\r\nEND:VCARD\r\n"
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("<", "<").gsub(">", ">").gsub("&", "&")
+ end
+
# Hands the app a throwaway store so the multi-contact routes can be
# exercised without touching the exchange fixture data, and yields it
# so a test can change a card mid-request-sequence. Only the store is
diff --git a/test/pro_tacts/vcard/test_parser.rb b/test/pro_tacts/vcard/test_parser.rb
index 5658828..31ace17 100644
--- a/test/pro_tacts/vcard/test_parser.rb
+++ b/test/pro_tacts/vcard/test_parser.rb
@@ -1,5 +1,7 @@
require_relative "../../test_helper"
+require_relative "../../photo_card"
+require "base64"
require "hegel"
require "pro_tacts/vcard/parser"
@@ -159,6 +161,57 @@ class VCardParserTest < Minitest::Test
assert_equal "abcd", properties(card("NOTE:ab", "\tcd")).first.value
end
+ ## Pictures
+
+ # The two shapes a picture arrives in, built as captured
+ # (test/photo_card.rb, from log/unhandled): the parameter section on
+ # one physical line however long, the base64 payload folded one
+ # break below it. The parser reads both without a length ceiling
+ # because the line split comes before the token scan — nothing here
+ # has to know how long any line is.
+ def test_a_photos_parameters_and_payload_read_whole
+ image = PhotoCard.image(600)
+ photo = PhotoCard.property(PhotoCard::PHOTO_PARAMETERS, image)
+ property = properties(card(photo)).first
+
+ assert_equal PhotoCard::PHOTO_PARAMETERS.map { it.split("=", 2) }, property.parameters
+ assert_equal Base64.strict_encode64(image), property.value
+ end
+
+ # The memoji shape adds VND-63-MEMOJI-DETAILS, whose base64 plist
+ # runs the parameter section to 1,683 octets on one line — past the
+ # 75-octet fold by an order of magnitude, with the payload folded
+ # correctly beneath it.
+ def test_a_memojis_unfolded_parameter_line_reads
+ image = PhotoCard.image(600)
+ memoji = PhotoCard.property(PhotoCard::MEMOJI_PARAMETERS, image)
+ property = properties(card(memoji)).first
+
+ assert_equal PhotoCard::MEMOJI_PARAMETERS.map { it.split("=", 2) }, property.parameters
+ assert_equal Base64.strict_encode64(image), property.value
+ end
+
+ # Any image and any memoji details, folded the client's way, read
+ # back whole — the parser's correctness rests on the fold shape,
+ # not on the two captured sizes.
+ def test_a_photo_folded_like_the_client_reads_back_whole
+ Hegel.test do |tc|
+ image = tc.draw(text(max_size: 4_000))
+ details = tc.draw(text(max_size: 2_000))
+ parameters = [
+ PhotoCard::MEMOJI_CROP,
+ "VND-63-MEMOJI-DETAILS=#{Base64.strict_encode64(details)}",
+ "ENCODING=b",
+ "TYPE=JPEG",
+ ]
+
+ property = properties(card(PhotoCard.property(parameters, image))).first
+
+ raise "payload did not survive" unless property.value == Base64.strict_encode64(image)
+ raise "parameters did not survive" unless property.parameters == parameters.map { it.split("=", 2) }
+ end
+ end
+
## Lines
# lines pairs each property with the exact bytes it came from — the