feat(contacts): page list_contacts with a book-bound cursor; get_contact by href (v0.3.0) #39

Merged
jlxq0 merged 4 commits from feat/t_6e196692-paging into main 2026-10-24 03:07:15 +00:00
Owner

Card t_6e196692, AC2 and AC3 (Julian approved contact paging and get-by-href directly). Second of two sequential PRs; stacked on the v0.2.2 dependency patch.

Why

CardDAV has no server-side paging. list_contacts fetched every card's body with one addressbook-query and returned the first limit (max 100), so the Contacts book (366 cards) could never be enumerated past 100, and there was no way to fetch one card by its href.

What

  • Paging (src/contact_page.rs): one PROPFIND Depth: 1 lists hrefs and ETags (no bodies) plus the book's getctag; the page is the first limit hrefs in byte order after the cursor; only those bodies are fetched with addressbook-multiget, in the version requested. The cursor is the last href wrapped with the address book and the ctag of its page, encoded opaquely (c1.<hex(json)>). Properties: a card that exists throughout is returned exactly once; a cursor for another book is refused before any request; paths are compared percent-decoded so @ and %40 name the same book; members the server lists outside the book (or off this origin) are dropped, and a listing whose members all fall outside the named book is refused rather than answered as empty; a member that vanished between listing and fetch is counted in missing; changed_since_cursor carries the enumeration's first ctag and stays true once the book moved (a card added behind the cursor is not revisited). list_contacts gains cursor and returns next_cursor, complete, total, ctag, changed_since_cursor, missing.
  • get_contact(contact_href, version): one card with its ETag, rendered as the server does by default or in the requested version; foreign hrefs are refused by the client's origin confinement, a same-origin resource that is not a vCard is refused by its Content-Type, and the server's permissions decide the rest. A 403 is now reported as a refusal of that resource instead of a session expiry (only a 401 ends the session).
  • src/fake_stalwart.rs (tests only): the fake server moves out of contact_write.rs and gains PROPFIND, multiget with a 404 propstat for a missing member, a moving ctag, and an injectable out-of-book member.
  • Version 0.3.0; CHANGELOG; README tools and model; AGENTS.md scope and two pitfalls.

Out of scope here, per the card: sync-collection tokens, sharing, client UI.

Evidence

  • 211 tests (191 before): 250-card enumeration through the client in three bounded pages with no duplicate or missing href and the other book not counted; concurrent edits (delete ahead, insert ahead and behind) never duplicate and are reported; cursor from another book refused; tampered cursor refused; limits clamped; vanished member skipped; injected out-of-book member dropped; get_contact renders 3.0/4.0 with the same ETag, refuses a foreign origin, reports not-found.
  • Mutations each caught by at least one test: cursor accepted for any book (2), cursor href revisited (4), ctag change hidden (2), foreign members kept (1), no next cursor (5), sibling-prefix book admitted (1), complete inverted (1), sticky flag removed (3), raw-href body matching (1), undecoded path comparison (2), all-outside guard disabled (1).
  • fmt, clippy pedantic on 1.93 and 1.99, cargo audit, cargo deny check, gitleaks: clean.
  • Independent review before this PR (fresh agent, against the diff, RFC 6352 and the Stalwart source): round 1 SHOULD-FIX (a 403 on a caller-typed href was reported as session expiry and evicted the token's validation; two untested lines; per-page instead of sticky change flag; matching by raw href), fixed in 3910aba; round 2 SHOULD-FIX (a book href typed with @ instead of %40 produced an empty, complete page while the old path returned the cards), fixed in c416930 and pinned in b3331e4; confirmation pass PASS.

Live proof (AC4) follows Clark's rollout: >100 synthetic contacts created, enumerated and read back through a real MCP client, then deleted conditionally and verified absent; no real contact is written.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE

Card t_6e196692, AC2 and AC3 (Julian approved contact paging and get-by-href directly). Second of two sequential PRs; stacked on the v0.2.2 dependency patch. ## Why `CardDAV` has no server-side paging. `list_contacts` fetched every card's body with one `addressbook-query` and returned the first `limit` (max 100), so the Contacts book (366 cards) could never be enumerated past 100, and there was no way to fetch one card by its href. ## What - **Paging** (`src/contact_page.rs`): one `PROPFIND Depth: 1` lists hrefs and `ETag`s (no bodies) plus the book's `getctag`; the page is the first `limit` hrefs in byte order after the cursor; only those bodies are fetched with `addressbook-multiget`, in the `version` requested. The cursor is the last href wrapped with the address book and the `ctag` of its page, encoded opaquely (`c1.<hex(json)>`). Properties: a card that exists throughout is returned exactly once; a cursor for another book is refused before any request; paths are compared percent-decoded so `@` and `%40` name the same book; members the server lists outside the book (or off this origin) are dropped, and a listing whose members all fall outside the named book is refused rather than answered as empty; a member that vanished between listing and fetch is counted in `missing`; `changed_since_cursor` carries the enumeration's first `ctag` and stays true once the book moved (a card added behind the cursor is not revisited). `list_contacts` gains `cursor` and returns `next_cursor`, `complete`, `total`, `ctag`, `changed_since_cursor`, `missing`. - **`get_contact(contact_href, version)`**: one card with its `ETag`, rendered as the server does by default or in the requested version; foreign hrefs are refused by the client's origin confinement, a same-origin resource that is not a vCard is refused by its `Content-Type`, and the server's permissions decide the rest. A 403 is now reported as a refusal of that resource instead of a session expiry (only a 401 ends the session). - `src/fake_stalwart.rs` (tests only): the fake server moves out of `contact_write.rs` and gains PROPFIND, multiget with a 404 `propstat` for a missing member, a moving `ctag`, and an injectable out-of-book member. - Version 0.3.0; CHANGELOG; README tools and model; AGENTS.md scope and two pitfalls. Out of scope here, per the card: sync-collection tokens, sharing, client UI. ## Evidence - 211 tests (191 before): 250-card enumeration through the client in three bounded pages with no duplicate or missing href and the other book not counted; concurrent edits (delete ahead, insert ahead and behind) never duplicate and are reported; cursor from another book refused; tampered cursor refused; limits clamped; vanished member skipped; injected out-of-book member dropped; `get_contact` renders 3.0/4.0 with the same `ETag`, refuses a foreign origin, reports not-found. - Mutations each caught by at least one test: cursor accepted for any book (2), cursor href revisited (4), ctag change hidden (2), foreign members kept (1), no next cursor (5), sibling-prefix book admitted (1), `complete` inverted (1), sticky flag removed (3), raw-href body matching (1), undecoded path comparison (2), all-outside guard disabled (1). - fmt, clippy pedantic on 1.93 and 1.99, `cargo audit`, `cargo deny check`, gitleaks: clean. - Independent review before this PR (fresh agent, against the diff, RFC 6352 and the Stalwart source): round 1 SHOULD-FIX (a 403 on a caller-typed href was reported as session expiry and evicted the token's validation; two untested lines; per-page instead of sticky change flag; matching by raw href), fixed in 3910aba; round 2 SHOULD-FIX (a book href typed with `@` instead of `%40` produced an empty, complete page while the old path returned the cards), fixed in c416930 and pinned in b3331e4; confirmation pass PASS. Live proof (AC4) follows Clark's rollout: >100 synthetic contacts created, enumerated and read back through a real MCP client, then deleted conditionally and verified absent; no real contact is written. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
CardDAV has no server-side paging and list_contacts fetched every card's
body to return the first 25, so a book of 366 could never be enumerated
past 100. A page is now cut from a PROPFIND Depth:1 listing (hrefs and
ETags only), sorted by href, and only the page's bodies are fetched with
addressbook-multiget in the requested version. The cursor is the last href
wrapped with the address book and its getctag, encoded opaquely: a card
that exists throughout is returned exactly once, a cursor for another book
is refused before any fetch, members the server lists outside the book are
dropped, and changed_since_cursor reports a book that moved during the
enumeration. Results carry next_cursor, complete, total and ctag.

get_contact(contact_href, version) fetches one card with its ETag; the
client's origin confinement refuses foreign hrefs and the server's
permissions decide the rest.

The fake Stalwart moves to src/fake_stalwart.rs (tests only) and gains
PROPFIND, multiget with 404 for a vanished member, a moving ctag and an
injectable out-of-book member. 203 tests; mutations (cursor book check,
cursor href revisited, ctag change hidden, foreign members kept, no next
cursor) each fail at least one test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
A 403 from Stalwart (another account's resource, a share without rights) was
mapped with 401 to "session expired, reconnect" and evicted the token's
cached validation; get_contact made that reachable from a caller-typed href.
403 is now CardDavError::Forbidden, an invalid_params error, and only 401
evicts. get_contact refuses a same-origin resource whose Content-Type is not
a vCard.

The cursor's book is the resolved path (spellings of one href agree) and is
checked before the PROPFIND. The cursor carries the enumeration's first ctag,
so changed_since_cursor stays true once a change was seen rather than
reporting only the previous page. Bodies are matched to the page by resolved
path and the result carries `missing`, the members listed but not returned.
A second collection response cannot reset the ctag.

Tests pin the sibling-prefix exclusion (default2 is not under default), the
`complete` derivation in both directions, the sticky flag, the missing count,
the 403 mapping and the content-type refusal; a reviewer's two surviving
mutations now fail one test each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
Stalwart decodes the account segment on input and re-encodes it as %40 on
output, so a caller typing /dav/card/julian@kampong.social/default/ got an
empty, "complete" book from the paging path while the old REPORT path
returned the cards. Paths are now compared decoded (percent-encoding becomes
a direct dependency; it was already in the lock through url) in the member
filter, the cursor's book and the body matching, and a listing that named
members none of which fall under the book is an error naming the href
rather than an empty page. The fake gains a canonical-encoding mode that
re-spells hrefs like Stalwart; tests cover both spellings sharing a cursor,
get_contact through the typed spelling, and the all-outside refusal next to
a genuinely empty book. Mutations: raw-href body matching and encoded path
comparison each fail one test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
test(contacts): pin body matching by decoded path with a server that spells hrefs differently in its two responses
All checks were successful
CI / cargo (pull_request) Successful in 1m14s
CI / docker (pull_request) Successful in 1m15s
b3331e4d89
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZfeS2nAr54h4afQ8ShfVE
jlxq0 merged commit 4c35378a33 into main 2026-10-24 03:07:15 +00:00
Sign in to join this conversation.
No reviewers
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jlxq0/carddav-mcp!39
No description provided.