Skip to content

Commit 51a4454

Browse files
pi0claude
andcommitted
feat(webdav): a minimal RFC 4918 class-1 server
`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>
1 parent 8bd1590 commit 51a4454

22 files changed

Lines changed: 5132 additions & 22 deletions

‎.agents/architecture.md‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Deviations are noted per area below.
3636
| `lock.ts` | `PathLock` — `RENAME` takes it, `READ`/`WRITE` run outside it |
3737
| `subtree.ts` | `remapSubtree()` — the rename rewrite; internal, deliberately not in the public `path.ts` |
3838
| `ownership.ts` | who a new entry belongs to: `inode_init_owner()`'s set-gid rule, plus the `lchown`/`chmod` that applies it. Internal; used by the two NFS sessions' `#claim` |
39+
| `http.ts` | RFC 9110's `HTTP-date`, `Range` and `ETag` quoting — the one copy, shared by the two HTTP transports; `mountx/s3` re-exports every symbol under its own name |
3940
| `auto.ts` | `mountx/auto` — probe, then FUSE → 9P → NFS, each behind `await import()` |
4041

4142
### Drivers (`src/drivers/`)
@@ -122,6 +123,25 @@ is no RFC; everything is transcribed from Amazon's docs and named where it is us
122123
| `session.ts` | one request in, one reply out, streaming **both ways**. Derived ETags, multipart staged under a reserved prefix |
123124
| `server.ts` | loopback-only without credentials; ordered drain on `close()` |
124125

126+
## WebDAV (`src/webdav/`, exported as `mountx/webdav`)
127+
128+
The other transport that is not a mount, and the one a kernel can mount anyway
129+
without root or native code (`davfs2`, `mount_webdav`, the Windows redirector).
130+
**RFC 4918 class 1** — every method but `LOCK`/`UNLOCK` — transcribed from the RFC,
131+
with RFC 9110 for the HTTP it rides on and RFC 4331 for the quota pair.
132+
133+
| File | What |
134+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
135+
| `constants.ts` | the errno → HTTP status table, typed **total** over `ErrnoCode` (the same shape as `s3/constants.ts`'s), the protocol's literals, and the `propstat` phrases |
136+
| `protocol.ts` | pure: target ↔ `href` (decoded and encoded **per segment**), `Depth`/`Overwrite`/`Destination`, the two request grammars, `multistatus` and `error` |
137+
| `session.ts` | method semantics over one driver. No handle table and no `PathLock` — HTTP carries no per-connection state, so a request resolves its own paths and is done |
138+
| `server.ts` | the socket, and the only file here that imports `node:http`. Loopback-only without credentials; HTTP Basic with them |
139+
140+
The deliberate gaps, each recorded at its own definition: no locking (so the `DAV`
141+
header says `1, 3`, never `1, 2, 3`), no dead properties (`PROPPATCH` answers `403
142+
cannot-modify-protected-property` — a driver has nowhere to keep one), and no
143+
conditional requests (they arrive with `If`, which exists to carry lock tokens).
144+
125145
## CLI (`src/cli/`, the `mountx` bin, `pnpm mountx` from source)
126146

127147
A demo and a test bench, not a mount tool: it mounts this package's own `README.md`
@@ -197,10 +217,24 @@ The facts no single file's header can own.
197217
after a failure, and no probe when a transport is named. `p9ModuleRefusal` lives
198218
there rather than in `9p/probe.ts` because it is a judgement call the no-fallback
199219
rule forces, not a fact about the host.
200-
- **`mountx/s3` is outside `auto` on purpose** — `auto`'s contract is a mountpoint,
201-
and the gateway never produces one.
220+
- **`mountx/s3` and `mountx/webdav` are outside `auto` on purpose** — `auto`'s
221+
contract is a mountpoint, and neither serving transport produces one. WebDAV is
222+
the one whose _client_ can produce one (`davfs2`, `mount_webdav`), which is a fact
223+
about the host's tooling rather than something this package does.
224+
- **`src/webdav/protocol.ts` imports `src/s3/xml.ts`.** The second deliberate
225+
cross-transport dependency, after 9P→`fuse/flags.ts`, and the same argument: a
226+
bounded XML encoder and a copying, DOCTYPE-refusing parser are facts about XML, not
227+
about S3, and a second hardened parser is a second thing to get wrong. What it
228+
costs is namespaces — the parser reports local names — which `webdav/protocol.ts`
229+
documents in full at its header. `xml.ts` pulls in only `s3/constants.ts`, so
230+
`mountx/webdav` does not load a signature or a chunked decoder.
231+
- **`src/http.ts` is the HTTP the two HTTP transports share** — `formatHttpDate` /
232+
`parseHttpDate`, `parseRange` and the `Content-Range`/`ETag` spellings, all
233+
RFC 9110. It was `src/s3/protocol.ts`'s until WebDAV needed the same three;
234+
`s3/protocol.ts` re-exports every symbol under its old name, so `mountx/s3`'s
235+
surface never moved.
202236
- **Platforms.** FUSE is Linux; 9P is Linux and root-only; NFS is Linux (root) and
203-
macOS (no root, behind a consent gate); S3 is anywhere. macOS gets NFS by necessity
237+
macOS (no root, behind a consent gate); S3 and WebDAV are anywhere. macOS gets NFS by necessity
204238
— macFUSE is a third-party kext with its own dialect, so `src/fuse/` cannot serve
205239
it.
206240
- **`memory.ts` is the only driver with `mountx.mknod`**, which is what keeps the

‎.agents/invariants.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,22 @@ no cast.
4848

4949
**The errno table is transcribed once**, in `src/errors.ts`. The addon reports a raw
5050
positive `errno` and lets `src/fuse/native.ts` name it, rather than carrying a second
51-
copy of the table in another language where the two would drift.
51+
copy of the table in another language where the two would drift. The same argument
52+
covers a wire format two transports share: RFC 9110's `HTTP-date`, `Range` and `ETag`
53+
spellings live in `src/http.ts`, which `src/s3/protocol.ts` re-exports under its own
54+
names and `src/webdav/` imports directly — one transcription, two HTTP transports.
5255

5356
## Wire protocols
5457

5558
**Wire constants are transcribed, never guessed or borrowed from host `node:fs`.**
5659
FUSE constants come from the kernel's `include/uapi/linux/fuse.h`; NFS constants come
5760
from RFC 1813/5531/4506 and, for v4.1, RFC 8881 with RFC 5662 for the XDR it does not
5861
spell out; 9P constants come from the kernel's `include/net/9p/9p.h` (both header
59-
sources pinned at tag v6.12) with diod's `protocol.md` as the prose reference; the
60-
`fusermount3` handshake and the Node-API declarations come from libfuse's and Node's
61-
own sources, both named where they are used.
62+
sources pinned at tag v6.12) with diod's `protocol.md` as the prose reference; WebDAV's methods,
63+
statuses, properties and precondition names come from RFC 4918, with RFC 4331 for the
64+
quota pair and RFC 9110 for the HTTP underneath; the `fusermount3` handshake and the
65+
Node-API declarations come from libfuse's and Node's own sources, both named where
66+
they are used.
6267

6368
**The wire's `O_*` and a driver's `O_*` are different namespaces.**
6469
`fuse_open_in.flags` is the Linux kernel's; `FsDriver` is a subset of
@@ -77,7 +82,9 @@ NFSv4.1, since COMPOUND is the one procedure everything else travels inside.
7782
one, a thrown value becomes a negative errno on FUSE (unknown → `EIO`), a positive
7883
Linux errno in an `Rlerror` on 9P, an `nfsstat3` on NFSv3, a legal `nfsstat4` on
7984
NFSv4.1 (an escaped `XdrError` → `NFS4ERR_BADXDR`, anything else →
80-
`NFS4ERR_SERVERFAULT`) and one S3 XML error body on the gateway, and a dev-mode
85+
`NFS4ERR_SERVERFAULT`), one S3 XML error body on the gateway, and one HTTP status —
86+
mapped from the errno by `src/webdav/constants.ts`'s table, with a `DAV:` `<error>`
87+
document when there is a §16 condition to carry — on WebDAV, and a dev-mode
8188
assertion tracks it per request id. A retried `(session, slot, sequence)` on v4.1
8289
answers from the slot's reply cache and re-runs nothing.
8390

‎.agents/roadmap.md‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ area whose code it changes, not the area that motivated it.
3737
- **`mountx/auto` never falls back after a mount failure**, and never probes
3838
when a transport is named. The probe decides from host facts, once — a silent
3939
second attempt would hand back different semantics than the error nobody saw.
40-
- **The S3 gateway stays out of `mountx/auto`.** Auto's whole contract is a
41-
mountpoint and that transport never produces one.
40+
- **The two HTTP transports stay out of `mountx/auto`.** Auto's whole contract
41+
is a mountpoint, and neither `mountx/s3` nor `mountx/webdav` produces one —
42+
even though a WebDAV _client_ (`davfs2`, `mount_webdav`) can, which is a fact
43+
about the host's tooling rather than something this package does.
4244
- **NLM byte-range locking is out of scope**, hence NFS mounts use `nolock`.
4345
- **Per-call caller credentials are not going into `FsDriver`.** The shape would
4446
be a `mountx.*` extension carrying the caller's uid/gid/groups into each
@@ -239,6 +241,48 @@ area whose code it changes, not the area that motivated it.
239241
attribute's "deliberately absent, it is the OPEN step's call" note predates
240242
the OPEN step making that call.
241243

244+
## WebDAV
245+
246+
- **No `LOCK`/`UNLOCK`, so the share is class 1.** This is the one gap with a
247+
visible cost rather than a theoretical one: macOS's `mount_webdav` mounts a
248+
class-1 share **read-only**, and the Windows redirector has its own
249+
objections, so the transport that exists to be the unprivileged mount path
250+
cannot yet be an unprivileged _writable_ mount path on either. What it needs
251+
is a lock table (tokens, timeouts, depth-0 and depth-infinity scope) plus the
252+
`If` header, which is the other half — a request proves it holds a lock by
253+
carrying the token there, and implementing `If` without locks would be
254+
answering a question nothing can ask. `src/9p/locks.ts` is the nearest
255+
precedent for the table, though the semantics are not the same: 9P's are POSIX
256+
byte ranges owned by a client/proc pair, WebDAV's are whole-resource (or
257+
whole-subtree) and owned by a token the server minted.
258+
- **Requested properties lose their namespace.** `src/s3/xml.ts`'s parser
259+
reports an element's local name and drops its prefix, which is right for the
260+
grammar and wrong for a property in a namespace other than `DAV:` — Finder's
261+
and Office's `Win32*` properties come back as bare local names in `DAV:`,
262+
inside the `404` propstat. Bounded to properties this server does not have,
263+
and fixing it means teaching that parser to track `xmlns` bindings, which is a
264+
change to a module the S3 gateway depends on.
265+
- **No conditional requests.** `If-Match`, `If-None-Match`, `If-Modified-Since`
266+
and `If-Unmodified-Since` are ignored rather than half-honoured. `mountx/s3`
267+
implements RFC 9110's four over the same derived ETag, so the codec is
268+
written; what is deliberate is the _timing_ — doing these without `If` would
269+
leave the one header WebDAV adds as the conspicuous hole, so they arrive with
270+
the locking work above.
271+
- **`PROPPATCH` cannot store anything.** Every property is refused with `403
272+
cannot-modify-protected-property`, which is truthful for a server whose
273+
properties are all live, and is also what makes a client that sets
274+
`Win32LastModifiedTime` (Finder, Explorer) report a failure it did not expect.
275+
The cheap half is `getlastmodified` → `driver.utimes()` for a driver
276+
declaring `times`; the expensive half is dead properties, which need a store
277+
the driver interface does not have — a sidecar file would show up in every
278+
listing.
279+
- **The two HTTP servers duplicate their transport mechanics.**
280+
`src/webdav/server.ts` and `src/s3/server.ts` track connections, drain on
281+
`close()` and write a streaming reply the same way, deliberately not shared
282+
yet: the bind refusal's wording, the fallback error reply and the
283+
authentication are each transport's own. If a third HTTP-shaped transport
284+
appears, this is the duplication to remove first.
285+
242286
## Platforms
243287

244288
- **AppleDouble sidecars are undocumented in `docs/`.** macOS tags every new
@@ -256,6 +300,10 @@ area whose code it changes, not the area that motivated it.
256300
into `kNetFSAlternatePortKey` and then never reads it — it goes to port 111
257301
and fails `ECONNREFUSED`, fatal for a server with no portmapper. Worth it only
258302
if someone actually needs the volume to appear where Finder puts one.
259-
- **WebDAV and Windows support.** Not designed against; WebDAV is the
260-
unprivileged, zero-native-code path for macOS/Windows. Windows also has no
261-
`mount(8)`, so it stays out of the NFS transport's platform switch.
303+
- **Windows is still not designed against.** `mountx/webdav` is the
304+
unprivileged, zero-native-code path that could reach it — the Windows
305+
redirector mounts a WebDAV share with no kernel module and no root — but
306+
nothing here has been run on Windows, and the redirector wants class-2
307+
locking before it will write (see the WebDAV section). Windows also has no
308+
`mount(8)`, so it stays out of the NFS transport's platform switch either
309+
way.

‎.agents/testing.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,20 @@ test:9p:mount` / `pnpm test:root`) — 9P has no unprivileged route on any host,
9898
`oracle.test.ts` — a real `rclone`/`curl` against the gateway, gated on `command -v
9999
rclone`/`curl` and needing no root, so it runs as part of `pnpm test` and skips
100100
clean when either binary is absent.
101+
- `test/webdav/` — Tier 0/1, all of it socket-optional: `protocol.test.ts` (the two
102+
request grammars, the `multistatus`/`error` documents, and the target↔`href`
103+
mapping round-tripped — the security-relevant half, since a name is the only thing
104+
that decides which resource a request reaches), `session.test.ts` (in-process
105+
against the memory driver, no sockets: RFC 4918's per-method semantics, the `207`
106+
partial-failure shapes for `DELETE` and `COPY`, Basic auth, and the one-reply
107+
discipline), and `server.test.ts` (real sockets driven with `fetch` plus one raw
108+
one: the bind gate, keep-alive, an unread body drained, a short body taking the
109+
connection with it, and an abandoned download releasing its handle), plus
110+
`oracle.test.ts` — a real `rclone` and `curl` against the server, gated on
111+
`command -v` and needing no root, so it runs as part of `pnpm test` and skips
112+
clean when either binary is absent. That is the file that catches a symmetric
113+
misreading of RFC 4918, the same role rclone plays for the S3 gateway. **No
114+
conformance column yet** — see Known gaps.
101115
- `test/auto.test.ts` — Tier 0 for `mountx/auto`: the preference order and the
102116
ruled-out reasons, answered for darwin and win32 from any host via the `platform`
103117
override. `test/auto-mount.test.ts` — Tier 2, whichever transport this host chose.
@@ -109,6 +123,14 @@ rclone`/`curl` and needing no root, so it runs as part of `pnpm test` and skips
109123

110124
## Known gaps
111125

126+
- **No WebDAV conformance column.** `test/webdav/` pins the protocol thoroughly but
127+
does not run the shared suite, because that needs an `FsDriver` built over the
128+
WebDAV session the way `test/s3/client.ts` is over the S3 one — and a WebDAV client
129+
can offer no `handles`, no `symlinks`, no `permissions` and no `truncate`, so most
130+
of what the column would report is already known from the protocol. It is worth
131+
writing anyway, for the same reason the S3 column was: the rows nobody predicted
132+
are the point. Until then `pnpm matrix` has five columns, not six.
133+
112134
- **No real-mount NFSv4.1 column.** Tier-2 `test/nfs/mount.test.ts` is v3-only; the
113135
dev host has no `mount.nfs` to write a v4.1 one against. The protocol is not
114136
unwitnessed, though: a VM guest supplies the missing client, and two of them have

‎AGENTS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# mountx
22

33
Mount a JavaScript filesystem: one driver interface (a subset of `node:fs/promises`),
4-
multiple transports — FUSE, 9P, NFS (v3 and v4.1) — plus an S3 gateway (`mountx/s3`)
5-
that serves the same driver to an S3 client instead of mounting it, deliberately
6-
outside `mountx/auto`.
4+
multiple transports — FUSE, 9P, NFS (v3 and v4.1) — plus two that serve the same
5+
driver over HTTP instead of mounting it, an S3 gateway (`mountx/s3`) and a WebDAV
6+
server (`mountx/webdav`), both deliberately outside `mountx/auto`.
77

88
**Conventions:** pure JS/TS, zero runtime deps, pure-JS-first. Single package with
99
subpath exports. Small conventional commits to `main`, `pnpm test` green before each
@@ -28,12 +28,14 @@ any rule below that looks removable.
2828
| `src/harness.ts` | `createLoopback(driver)` — what driver authors test against |
2929
| `src/lock.ts` | `PathLock`, taken by `RENAME` on every transport |
3030
| `src/subtree.ts` | `remapSubtree()` — the rename rewrite all three handle tables share (internal) |
31+
| `src/http.ts` | RFC 9110's `HTTP-date`, `Range` and `ETag` quoting — shared by the two HTTP transports |
3132
| `src/auto.ts` | `mountx/auto`: probe, then FUSE → 9P → NFS, each via `await import()` |
3233
| `src/drivers/` | `memory` (the only `mountx.mknod` implementation), `node-fs`, `unstorage`, `handle.ts` |
3334
| `src/fuse/` | `mountx/fuse` — protocol 7.41, root and `fusermount3` mount paths, `exec.ts` (shared spawn/`Deadline`) |
3435
| `src/9p/` | `mountx/9p` — 9P2000.L, `trans=unix` by default, one session per connection |
3536
| `src/nfs/` | `mountx/nfs` — a version router over `v3/` (RFC 1813 + MOUNT) and `v4/` (NFSv4.1); Linux and macOS |
3637
| `src/s3/` | `mountx/s3` — SigV4 gateway over HTTP, path-style, one bucket per driver |
38+
| `src/webdav/` | `mountx/webdav` — RFC 4918 class 1 over HTTP; no locking, and the `DAV` header says so |
3739
| `src/cli/` | the `mountx` bin — a demo and test bench that mounts this package's README |
3840
| `native/` | the Zig Node-API addon and its generated embed (`prebuilt.mjs`) |
3941
| `test/` | Tier 0/1/2 suites and the shared conformance suite — see `.agents/testing.md` |

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ npx mountx
7373
- [Troubleshooting](https://mountx.vercel.app/guide/troubleshooting) — the things that will bite you, and how to recover.
7474
- [Transports](https://mountx.vercel.app/transports) — FUSE, 9P and NFS, what each costs, and how to pin one.
7575
- [S3 Gateway](https://mountx.vercel.app/transports/s3) — serve a driver as an S3-compatible bucket for `rclone`, the AWS CLI or an SDK, no mount involved.
76+
- [WebDAV](https://mountx.vercel.app/transports/webdav) — serve a driver over RFC 4918 to `rclone`, `curl` or `davfs2`, no mount involved either.
7677
- [Reference](https://mountx.vercel.app/reference) — every entry point, option and type.
7778

7879
## Development

‎build.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default defineBuildConfig({
1212
"./src/nfs/index.ts",
1313
"./src/9p/index.ts",
1414
"./src/s3/index.ts",
15+
"./src/webdav/index.ts",
1516
"./src/drivers/memory.ts",
1617
"./src/drivers/node-fs.ts",
1718
"./src/drivers/unstorage.ts",

0 commit comments

Comments
 (0)