Skip to content

feat(webdav): an RFC 4918 class-1/2/3 WebDAV server - #15

Open
pi0x wants to merge 14 commits into
mainfrom
feat/webdav
Open

feat(webdav): an RFC 4918 class-1/2/3 WebDAV server#15
pi0x wants to merge 14 commits into
mainfrom
feat/webdav

Conversation

@pi0x

@pi0x pi0x commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

mountx/webdav serves an FsDriver over WebDAV — the second transport that is not a mount, and the one whose client produces a mountpoint anyway, with no root on the server side and no native code at all.

RFC 4918 classes 1, 2 and 3, and the DAV header says so because each class is answered rather than claimed: OPTIONS, HEAD, GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK, with the If header enforcing the locks and RFC 9110's conditionals on GET/HEAD/PUT. Loopback-only without credentials, HTTP Basic with them; streaming bodies both ways; one reply per request with a dev-mode assertion behind it. Outside mountx/auto, for the same reason mountx/s3 is.

import { createWebdavServer } from "mountx/webdav";

await using server = await createWebdavServer(driver).listen();
// sudo mount -t davfs $url /mnt/point

The shape

file
constants.ts the errno → HTTP status table, typed total over ErrnoCode, the protocol's literals, the propstat reason phrases
protocol.ts pure: target ↔ href, Depth/Overwrite/Destination/Timeout/Lock-Token/If, the three grammars, multistatus/error/lock documents
locks.ts the write-lock table — pure, synchronous and clockless: now is an argument, never a call
session.ts method semantics over one driver
server.ts the socket, and the only file here importing node:http

Locking is the real thing: both scopes and both kinds, §9.10.5's compatibility table checked up and down the tree, urn:uuid: tokens, lazy lease expiry, refresh, §7.3's locked empty resource, a MAX_LOCKS cap answering 503, and §7.6's rule that a lock never follows its resource. The If header implements §10.4.2 in full, with §10.4.1's two purposes kept apart, so (Not <DAV:no-lock>) submits a token from a list that was never true.

Decisions worth a reviewer's attention

  • src/http.ts. HTTP-date, Range, ETag quoting and the conditional-request rules are RFC 9110, not S3, and both HTTP transports need them — so they moved out of src/s3/protocol.ts, which re-exports every symbol under its old name. mountx/s3's surface and its 629 tests are untouched.
  • webdav/protocol.ts imports s3/xml.ts — the second deliberate cross-transport dependency after 9P → fuse/flags.ts — rather than hardening a second bounded XML parser. The cost is namespaces (that parser reports local names), documented at the file header and left in the roadmap: fixing it means changing the parser and the encoder that mountx/s3 depends on, for a bounded cosmetic deviation inside a 404 propstat.
  • Two RFC ambiguities, resolved and flagged in the source: an overwriting MOVE/COPY onto a locked destination keeps the destination's lock (§7.6 is more specific than §6.1's unmapping rule); entity-tag comparison inside If is weak, because §10.4.9's own example carries W/"A weak ETag" and expects a match. If-Match/If-None-Match keep RFC 9110's strong/weak split.

Three bugs caught in review, each with a test that fails without the fix

  • COPY of a symlink to an ancestor never terminated — the top-level source was stated, so a link to a collection answered isDirectory() and was walked; isPathInside compares text and never saw it. Unbounded work from one request.
  • DELETE on a link to a collection would have emptied what it points at rather than removing the link (a stat where an lstat belongs).
  • COPY descending into a link inside the tree — same non-termination, one level down.

Tests

pnpm test: 3209 passed, 326 skipped, green at every commit. src/webdav/ at 97% statements / 95% branches. Four layers: the wire alone, the session in-process, the server over real sockets, and two oracles — rclone/curl at the protocol level, and a real kernel mount through davfs2 (test/webdav/mount.test.ts, sudo-gated, skips clean without mount.davfs).

That last one is new and is the strongest evidence here: an ordinary filesystem workload — write, read back, rename, unlink, non-ASCII names, a 1 MiB file — carried by the kernel, verified on the driver's own side with node:fs. It also pins the class-2 interop: davfs2 reads DAV: 1, 2, 3 and takes a lock per write, asserted rather than described, and re-advertising class 1 fails that case and no other. (Against class 1 davfs2 warns and writes anyway — measured, and kept in .agents/environment.md, because it is the evidence that macOS's mount_webdav is a different client with a different rule.)

macOS and Windows are expected to work against a class-2 share and neither was run — this host is Linux. The docs state that separation rather than promising it.

Still open, recorded in the roadmap: the namespace fix above, a WebDAV conformance-matrix column, and the duplicated drain mechanics between the two HTTP servers.

🤖 Generated with Claude Code

pi0 and others added 2 commits July 31, 2026 22:04
`HTTP-date`, the `Range` grammar and the `ETag` quoting are facts about
RFC 9110, not about S3, and `mountx/webdav` needs the same three. They move
to `src/http.ts`; `src/s3/protocol.ts` re-exports every symbol under the name
it already had, so `mountx/s3`'s surface is unchanged.

Same argument as the one errno table in `src/errors.ts` (AGENTS.md,
invariant 6): a wire format transcribed twice is a wire format that will be
transcribed differently twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`mountx/webdav` serves an `FsDriver` over WebDAV: OPTIONS, HEAD, GET, PUT,
DELETE, MKCOL, COPY, MOVE, PROPFIND and PROPPATCH, in the transport shape the
rest of `src/` uses — constants, a pure protocol layer, a session that never
rejects, and a server that is the only file importing `node:http`. Like
`mountx/s3` it produces no mountpoint, so it stays outside `mountx/auto`.

Class 2 is absent and says so: no LOCK/UNLOCK, and the `DAV` header answers
`1, 3` rather than claiming a class whose methods would be 405 (invariant 5).
That costs a writable mount on macOS's `mount_webdav` and on the Windows
redirector, which is written down at the session's header and tracked in
`.agents/roadmap.md` rather than discovered later.

Two recursive operations refuse to follow a symbolic link, and both would be
destructive if they did: DELETE takes an `lstat` and recurses on the readdir
dirent, so it removes the link rather than emptying what it points at; COPY
reports a link to a collection with 403 instead of descending into one, since
a link back to an ancestor makes the walk revisit a subtree it is still
writing into.

`src/webdav/protocol.ts` imports `src/s3/xml.ts` — the second deliberate
cross-transport dependency after 9P -> fuse/flags.ts — rather than hardening a
second bounded XML parser. What that costs is namespaces, documented in full
at that file's header.

Tests: the wire on its own, the session in-process against the memory driver,
the server over real sockets, and an oracle running real rclone and curl,
gated on `command -v` so `pnpm test` stays green without them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mountx Ready Ready Preview Jul 31, 2026 10:57pm

Request Review

pi0 and others added 12 commits July 31, 2026 22:26
RFC 4918 §6 and §7 over one driver: a pure, synchronous, clockless lock
table (`src/webdav/locks.ts` — `now` is an argument, the token minter is
an option), `LOCK`, `UNLOCK`, and the `Timeout`, `Lock-Token` and `If`
grammars in `protocol.ts`.

Both scopes and both depths, §9.10.5's compatibility table, leases that
lapse without a timer, refresh through `If` (§9.10.2), and §7.3's locked
empty resource for a `LOCK` on an unmapped URL. `supportedlock` and
`lockdiscovery` stop being truthfully empty and report what is granted
and held, which is what makes `DAV: 1, 2, 3` a statement rather than a
claim. A lock never follows its resource (§7.6): every `DELETE`, `MOVE`
and `COPY` deletes the locks whose roots it unmapped (§6.1 point 8),
each root checked rather than assumed.

The `If` header is parsed here and enforced in the next commit — a
mutating request does not yet have to carry the token it holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RFC 4918 §10.4 evaluated and enforced. `#guard` runs once per request
and keeps §10.4.1's two purposes apart: every state list is evaluated (a
header that is wholly false is `412`), and every positive state token in
it counts as submitted whether or not its list was true — which is what
makes §10.4.8's `(Not <DAV:no-lock>)` idiom work.

Every mutating method then consults it. `PUT`, `DELETE`, `MKCOL`,
`PROPPATCH`, a `COPY` destination, both ends of a `MOVE`, and the empty
resource a `LOCK` creates each answer `423` with `lock-token-submitted`
naming the lock roots in the way (§7.5.2), and a lock on a *member* of a
tree is a `207` carrying `423` for it (§9.6.1) with nothing deleted.
Membership is checked against the parent as well as the resource, which
is the only thing a depth-0 collection lock protects (§7.4). `GET`,
`HEAD` and `PROPFIND` stay unprotected (§7) while still honouring `If`.

RFC 9110's entity-tag list parser and its two comparison functions move
to `src/http.ts` — the `If` header matches tags with the same code the
S3 gateway does — and `src/s3/protocol.ts` re-exports `parseETagList`,
`ETag` and `ETagList` under the names they had, so `mountx/s3`'s surface
is unchanged and its 629 tests are untouched.

`test/webdav/oracle.test.ts` drives the whole round trip through real
curl: LOCK a null URL (201), PUT refused 423, PUT with `If` 204, UNLOCK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test/webdav/oracle.test.ts` drives rclone and curl at the protocol level;
nothing had ever put a kernel in front of this server. `mount.davfs` does,
over FUSE, and the workload is ordinary syscalls with no WebDAV vocabulary
in it: a tree, seeded bytes read back and compared, a 1 MiB file, rename,
unlink, rmdir, names carrying a space and non-ASCII, sizes and mtimes,
HTTP Basic, and the errno cases. Both directions are checked on the
driver's own side against a node-fs driver over a mkdtemp directory.

Two facts about davfs2 shape it. It caches, so a driver-side check is a
bounded poll and the read path is proved by seeding the driver *before*
mounting — a read-back of what the mount just wrote never reaches the
server. And teardown is `umount -i`: the `umount.davfs` helper unmounts
and then waits for a daemon that a container with no reaping init never
reaps.

Gated on an in-file `davfsClientProbe()` — Linux, mount.davfs, /dev/fuse,
and root, since davfs2 refuses an unprivileged caller without an
/etc/fstab entry — so a plain `pnpm test` skips it with a reason.

Class 1 turns out to cost nothing here: davfs2 reads `DAV: 1, 3`, warns,
and mounts read-write anyway. Not one LOCK reaches the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`If-Match`, `If-None-Match`, `If-Modified-Since` and `If-Unmodified-
Since`, evaluated in §13.2.2's order and before the `Range` — a `304` and
a `412` are answers about the whole representation, so a range evaluated
first would answer `206` to a request whose precondition failed. `304`
carries the validators and no content, not even a length (§15.4.5).

The rules were `src/s3/protocol.ts`'s and are transport-neutral, so they
move to `src/http.ts` beside RFC 9110's dates, `Range` and `ETag` — the
same treatment the first commit on this branch gave those. The one
S3-shaped part was the header lookup: SigV4 signs headers as they were
sent, so that transport keeps a list and cannot hand over the record
WebDAV already has. `evaluateConditionals` therefore takes a plain
lowercase record, and `src/s3/protocol.ts` keeps a wrapper of the same
name and signature that joins its repeated list-based fields (§5.3).
`parseETagList`, `ETag` and `ETagList` are re-exported unchanged;
`mountx/s3`'s surface and its 629 tests are untouched.

What the S3 gateway never had to answer is a `PUT` to a URL with no
representation, and §13.1 decides it per header: `If-Match` on nothing
is `412`, `If-None-Match` passes — which is where `If-None-Match: *`
means create-only — and the two date forms are ignored. `423` still
comes before `412`: a lock is a fact the client must resolve first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getlastmodified` → `driver.utimes()`, on a driver declaring the `times`
capability, answered `200` in its own propstat. Everything else stays
`403 cannot-modify-protected-property`: the rest of this server's
properties are live and derived, and a dead one needs a store the driver
interface does not have — a sidecar file would show up in every listing.

Two refusals that are not that one. A value that is not an `HTTP-date`
is `409`, which §9.2.1 defines as "the client has provided a value whose
semantics are not appropriate for the property", and it carries no §16
condition because there is none for it. A driver without `times` gets
`403` again: the capability is declared-or-inferred and never faked, so
on such a driver the property really is protected, and answering `200`
would be storing nothing.

`PROPPATCH` is also atomic now, which it could not observably be while
every instruction failed: §9.2 requires that instructions "either all be
executed or none executed", so one failure makes every settable property
`424 Failed Dependency` (§9.2.1) and nothing is written. Instructions are
evaluated in document order, which the same section makes normative, and
`parseProppatch` carries each `<set>` value alongside its name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.agents/roadmap.md` loses the three entries this branch closed —
locking, conditional requests, and the settable half of `PROPPATCH` —
and keeps what is still open, restated in the present tense as the
constraint the remaining work runs into: dead properties, the namespace
the XML parser drops, and the six methods RFC 9110's conditionals are
not honoured on. The Windows entry stops waiting for class-2 locking and
starts saying what is untested instead.

`docs/2.transports/6.webdav.md` gets a Locking section (scopes, tokens,
leases, the locked empty resource, why a lock never follows its
resource, and the three refusals `If` produces) and a Conditional
requests section, and its mounting note now separates what is verified
here from what is only predicted: macOS's `mount_webdav` and the Windows
redirector should write to a class-2 share, and neither has been run
against this one, because this machine is Linux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`DavLockTable.discard` bulk-deleted a subtree's locks, which read like
§6.1 point 8 and is not how the session applies it: `#discardUnmapped`
walks `within(path)` and checks each root, because at an overwritten
`MOVE` destination the root was remapped by the same request (§7.6) and
only the members that were not recreated are gone. One rule, one code
path, and the table keeps the query the rule is built on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LOCK` refuses `Depth: 1` before it reaches the lock table, so what is
left really is `LockDepth`; the two casts were saying so a second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`#copyTree` refuses a symbolic link to a collection it meets *inside*
the tree, for the reason its comment gives: the walk would revisit a
subtree it is still writing into and create the next level every pass.
At the **top** of the tree the same link was followed, because the
source is `stat`ed rather than `lstat`ed and a link to a collection
answers `isDirectory()`.

That is the end where it matters more. The destination-inside-source
refusal compares paths as text, so `COPY /link → /dst` with `/link → /`
passes it — the two names are unrelated — and the copy then runs until
the driver runs out of something: on the memory driver, never. One
request, unbounded work.

A link to a collection is now `403` at the top of the tree too. A link
to a *file* is still followed and its bytes copied (that cannot recur),
and `MOVE` is unaffected: it renames the link itself and walks nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	.agents/testing.md
Class 2 landed under this suite, and the header's "not one LOCK reaches the
server" stopped being true the moment it did. Measured both sides rather than
edited the sentence: against `DAV: 1, 3` davfs2 warns and writes without a
lock, against `DAV: 1, 2, 3` it takes one per write and releases it —
`OPTIONS 1, PROPFIND 2, HEAD 2, LOCK 2, PUT 2, UNLOCK 2` for one write and one
copy.

The second is now asserted rather than described, which makes this the only
test in the package where LOCK, the If header and UNLOCK are driven by a real
kernel client instead of by a `fetch` this repository wrote. Checked it has
teeth: advertising class 1 again fails this case and no other.

The class-1 observation is kept in `.agents/environment.md`, where a fact
about a client belongs — it is the evidence behind "macOS's mount_webdav is
the client that insists on class 2, and davfs2 is not".
@pi0x pi0x changed the title feat(webdav): a minimal RFC 4918 class-1 server feat(webdav): an RFC 4918 class-1/2/3 WebDAV server Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants