WebDAV: honour If-Match / If-None-Match on PUT, DELETE and MOVE, and return an ETag after PUT and tus finalize #492

Closed
opened 2026-09-11 21:03:41 +00:00 by Cordy · 1 comment
Owner

Status: verified at runtime against cairnd built from bd006ef; unchanged on main (e1c855d).

What happens

  • PUT with If-Match: "bogus" on an existing file → 204, and the file is overwritten. cairn-desktop's integration suite (interop run #32 on e4aead3) records the same for DELETE and MOVE with If-Match, and for PUT with If-None-Match: * onto an existing file.
  • Overwrite: F on MOVE is honoured (412) — that part works.
  • PUT (201/204) returns no ETag or Last-Modified, and tus finalize returns no ETag either, so a client must PROPFIND afterwards.

Why it matters

The desktop client (and the planned mobile clients) need this to avoid lost updates when two devices edit the same file: the client sends the ETag it last saw, and the server refuses the write if someone else changed the file in between. Without it the client can only re-check just before writing, which narrows the window but cannot close it. And because PUT returns no ETag, a same-size write by another client landing between our PUT and our PROPFIND is recorded under our content — the two copies then diverge silently.

Suggested fix

  • Evaluate If-Match / If-None-Match against the current ETag before PUT, DELETE and MOVE (source), answering 412 Precondition Failed on mismatch (RFC 7232 §3, RFC 4918 §10.4 / §12).
  • Set ETag (and Last-Modified) on PUT's 201/204 responses.
  • tus: return the final object's ETag on the PATCH that completes the upload (e.g. an ETag header on that 204).

Found while building the cairn-desktop WebDAV client (phase 2, Cordy/cairn-desktop#23/#29). Filed by Claude on behalf of @Cordy.

**Status: verified at runtime** against cairnd built from `bd006ef`; unchanged on `main` (`e1c855d`). ## What happens - `PUT` with `If-Match: "bogus"` on an existing file → **204, and the file is overwritten**. cairn-desktop's integration suite (interop run #32 on `e4aead3`) records the same for `DELETE` and `MOVE` with `If-Match`, and for `PUT` with `If-None-Match: *` onto an existing file. - `Overwrite: F` on MOVE *is* honoured (412) — that part works. - `PUT` (201/204) returns **no `ETag` or `Last-Modified`**, and tus finalize returns no ETag either, so a client must PROPFIND afterwards. ## Why it matters The desktop client (and the planned mobile clients) need this to avoid lost updates when two devices edit the same file: the client sends the ETag it last saw, and the server refuses the write if someone else changed the file in between. Without it the client can only re-check just before writing, which narrows the window but cannot close it. And because PUT returns no ETag, a same-size write by another client landing between our PUT and our PROPFIND is recorded under our content — the two copies then diverge silently. ## Suggested fix - Evaluate `If-Match` / `If-None-Match` against the current ETag before PUT, DELETE and MOVE (source), answering `412 Precondition Failed` on mismatch (RFC 7232 §3, RFC 4918 §10.4 / §12). - Set `ETag` (and `Last-Modified`) on PUT's 201/204 responses. - tus: return the final object's ETag on the PATCH that completes the upload (e.g. an `ETag` header on that 204). --- Found while building the cairn-desktop WebDAV client (phase 2, Cordy/cairn-desktop#23/#29). Filed by Claude on behalf of @Cordy.
Author
Owner

Shipped in v0.6.168, live on both dogfoods.

What landed:

  • PUT honours If-Match / If-None-Match (incl. *): checked against the current stored ETag before any bytes are written; mismatch → 412. Successful PUT now returns ETag and Last-Modified from a post-write Stat.
  • DELETE and MOVE/COPY check the same preconditions (MOVE/COPY against the source) when the headers are present.
  • tus: the completing PATCH returns the final file's ETag (finalize now Stats the assembled file), so resumable uploads end with the same handle a plain PUT gives.
  • ETag list parsing handles comma lists, W/ prefixes and * per RFC 9110.

TDD: lost-update round-trip tests (cond_test.go, etag_test.go) witnessed red on the runner first. Clients can now do read-modify-write without a race window.

Shipped in **v0.6.168**, live on both dogfoods. What landed: - **PUT** honours `If-Match` / `If-None-Match` (incl. `*`): checked against the current stored ETag before any bytes are written; mismatch → 412. Successful PUT now returns `ETag` and `Last-Modified` from a post-write Stat. - **DELETE** and **MOVE/COPY** check the same preconditions (MOVE/COPY against the source) when the headers are present. - **tus**: the completing PATCH returns the final file's `ETag` (finalize now Stats the assembled file), so resumable uploads end with the same handle a plain PUT gives. - ETag list parsing handles comma lists, `W/` prefixes and `*` per RFC 9110. TDD: lost-update round-trip tests (`cond_test.go`, `etag_test.go`) witnessed red on the runner first. Clients can now do read-modify-write without a race window.
Cordy closed this issue 2026-09-12 16:35:50 +00:00
Sign in to join this conversation.
No labels
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: Cordy/Cairn#492
No description provided.