feat(webdav): an RFC 4918 class-1/2/3 WebDAV server - #15
Open
pi0x wants to merge 14 commits into
Open
Conversation
`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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mountx/webdavserves anFsDriverover 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
DAVheader says so because each class is answered rather than claimed:OPTIONS,HEAD,GET,PUT,DELETE,MKCOL,COPY,MOVE,PROPFIND,PROPPATCH,LOCK,UNLOCK, with theIfheader enforcing the locks and RFC 9110's conditionals onGET/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. Outsidemountx/auto, for the same reasonmountx/s3is.The shape
constants.tsErrnoCode, the protocol's literals, thepropstatreason phrasesprotocol.tshref,Depth/Overwrite/Destination/Timeout/Lock-Token/If, the three grammars,multistatus/error/lock documentslocks.tsnowis an argument, never a callsession.tsserver.tsnode:httpLocking 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, aMAX_LOCKScap answering503, and §7.6's rule that a lock never follows its resource. TheIfheader 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,ETagquoting and the conditional-request rules are RFC 9110, not S3, and both HTTP transports need them — so they moved out ofsrc/s3/protocol.ts, which re-exports every symbol under its old name.mountx/s3's surface and its 629 tests are untouched.webdav/protocol.tsimportss3/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 thatmountx/s3depends on, for a bounded cosmetic deviation inside a404propstat.MOVE/COPYonto a locked destination keeps the destination's lock (§7.6 is more specific than §6.1's unmapping rule); entity-tag comparison insideIfis weak, because §10.4.9's own example carriesW/"A weak ETag"and expects a match.If-Match/If-None-Matchkeep RFC 9110's strong/weak split.Three bugs caught in review, each with a test that fails without the fix
COPYof a symlink to an ancestor never terminated — the top-level source wasstated, so a link to a collection answeredisDirectory()and was walked;isPathInsidecompares text and never saw it. Unbounded work from one request.DELETEon a link to a collection would have emptied what it points at rather than removing the link (astatwhere anlstatbelongs).COPYdescending 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/curlat the protocol level, and a real kernel mount through davfs2 (test/webdav/mount.test.ts, sudo-gated, skips clean withoutmount.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 readsDAV: 1, 2, 3and 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'smount_webdavis 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