diff --git a/.agents/architecture.md b/.agents/architecture.md index 5f15695..8b66100 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -27,16 +27,17 @@ Deviations are noted per area below. ## Core (`src/`) -| File | What | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `types.ts` | `FsDriver`, `FsCapabilities`, `StatsLike`/`DirentLike`/`FileHandleLike`, and the `mountx.*` namespace — **two live members**, `mknod` and `utimens`, no xattr | -| `errors.ts` | `ERRNO_CODES` (Linux), `fsError()` (byte-identical to `node:fs`'s), `errnoOf()` — the one errno table in the repo | -| `path.ts` | absolute POSIX helpers, `..` clamps at root; canonical paths early-return, `resolvePath()` returns `{ path, segments }` | -| `harness.ts` | `createLoopback(driver)` — normalize, fill gaps with `ENOSYS`, resolve capabilities. The method table is fixed **at construction** | -| `lock.ts` | `PathLock` — `RENAME` takes it, `READ`/`WRITE` run outside it | -| `subtree.ts` | `remapSubtree()` — the rename rewrite; internal, deliberately not in the public `path.ts` | -| `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` | -| `auto.ts` | `mountx/auto` — probe, then FUSE → 9P → NFS, each behind `await import()` | +| File | What | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `types.ts` | `FsDriver`, `FsCapabilities`, `StatsLike`/`DirentLike`/`FileHandleLike`, and the `mountx.*` namespace — **two live members**, `mknod` and `utimens`, no xattr | +| `errors.ts` | `ERRNO_CODES` (Linux), `fsError()` (byte-identical to `node:fs`'s), `errnoOf()` — the one errno table in the repo | +| `path.ts` | absolute POSIX helpers, `..` clamps at root; canonical paths early-return, `resolvePath()` returns `{ path, segments }` | +| `harness.ts` | `createLoopback(driver)` — normalize, fill gaps with `ENOSYS`, resolve capabilities. The method table is fixed **at construction** | +| `lock.ts` | `PathLock` — `RENAME` takes it, `READ`/`WRITE` run outside it | +| `subtree.ts` | `remapSubtree()` — the rename rewrite; internal, deliberately not in the public `path.ts` | +| `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` | +| `http.ts` | RFC 9110's `HTTP-date`, `Range`, `ETag` quoting, the two tag-comparison functions and §13.2.2's conditionals — the one copy, shared by the two HTTP transports; `mountx/s3` re-exports them under its own names | +| `auto.ts` | `mountx/auto` — probe, then FUSE → 9P → NFS, each behind `await import()` | ### Drivers (`src/drivers/`) @@ -122,6 +123,28 @@ is no RFC; everything is transcribed from Amazon's docs and named where it is us | `session.ts` | one request in, one reply out, streaming **both ways**. Derived ETags, multipart staged under a reserved prefix | | `server.ts` | loopback-only without credentials; ordered drain on `close()` | +## WebDAV (`src/webdav/`, exported as `mountx/webdav`) + +The other transport that is not a mount, and the one a kernel can mount anyway +without root or native code (`davfs2`, `mount_webdav`, the Windows redirector). +**RFC 4918 classes 1, 2 and 3** — every method the specification defines, write +locks included — transcribed from the RFC, with RFC 9110 for the HTTP it rides on +and RFC 4331 for the quota pair. + +| File | What | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 | +| `protocol.ts` | pure: target ↔ `href` (decoded and encoded **per segment**), `Depth`/`Overwrite`/`Destination`/`Timeout`/`Lock-Token`/`If`, the three request grammars, and every document | +| `locks.ts` | the write-lock table (§6, §7): pure, synchronous, **clockless** — `now` is an argument. Scope is a prefix test; a lock never follows its resource | +| `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 | +| `server.ts` | the socket, and the only file here that imports `node:http`. Loopback-only without credentials; HTTP Basic with them | + +The deliberate gaps, each recorded at its own definition: no dead properties +(`PROPPATCH` writes `getlastmodified` through `utimes` and answers `403 +cannot-modify-protected-property` for the rest — a driver has nowhere to keep a dead +one), RFC 9110's conditionals on `GET`/`HEAD`/`PUT` only, and no lock-null +resources (§7.3's _locked empty resource_ instead, which is a real file). + ## CLI (`src/cli/`, the `mountx` bin, `pnpm mountx` from source) A demo and a test bench, not a mount tool: it mounts this package's own `README.md` @@ -197,10 +220,28 @@ The facts no single file's header can own. after a failure, and no probe when a transport is named. `p9ModuleRefusal` lives there rather than in `9p/probe.ts` because it is a judgement call the no-fallback rule forces, not a fact about the host. -- **`mountx/s3` is outside `auto` on purpose** — `auto`'s contract is a mountpoint, - and the gateway never produces one. +- **`mountx/s3` and `mountx/webdav` are outside `auto` on purpose** — `auto`'s + contract is a mountpoint, and neither serving transport produces one. WebDAV is + the one whose _client_ can produce one (`davfs2`, `mount_webdav`), which is a fact + about the host's tooling rather than something this package does. +- **`src/webdav/protocol.ts` imports `src/s3/xml.ts`.** The second deliberate + cross-transport dependency, after 9P→`fuse/flags.ts`, and the same argument: a + bounded XML encoder and a copying, DOCTYPE-refusing parser are facts about XML, not + about S3, and a second hardened parser is a second thing to get wrong. What it + costs is namespaces — the parser reports local names — which `webdav/protocol.ts` + documents in full at its header. `xml.ts` pulls in only `s3/constants.ts`, so + `mountx/webdav` does not load a signature or a chunked decoder. +- **`src/http.ts` is the HTTP the two HTTP transports share** — `formatHttpDate` / + `parseHttpDate`, `parseRange`, the `Content-Range`/`ETag` spellings, the strong + and weak entity-tag comparisons, and `evaluateConditionals`, all RFC 9110. It was + `src/s3/protocol.ts`'s until WebDAV needed the same rules; `s3/protocol.ts` + re-exports every symbol under its old name, so `mountx/s3`'s surface never moved. + `evaluateConditionals` is the one wrapped rather than re-exported: SigV4 signs + headers as they were sent, so that transport keeps a list and joins its repeated + `If-Match`/`If-None-Match` lines (RFC 9110 §5.3) into the record the shared rule + reads, while WebDAV hands over the one `node:http` already gave it. - **Platforms.** FUSE is Linux; 9P is Linux and root-only; NFS is Linux (root) and - macOS (no root, behind a consent gate); S3 is anywhere. macOS gets NFS by necessity + macOS (no root, behind a consent gate); S3 and WebDAV are anywhere. macOS gets NFS by necessity — macFUSE is a third-party kext with its own dialect, so `src/fuse/` cannot serve it. - **`memory.ts` is the only driver with `mountx.mknod`**, which is what keeps the diff --git a/.agents/environment.md b/.agents/environment.md index d495321..2ed71e3 100644 --- a/.agents/environment.md +++ b/.agents/environment.md @@ -80,6 +80,17 @@ **Use libnfs whenever the wire format changes**: it shares none of our codecs, which is exactly what the Tier-1 JS client — built from the server's own codecs — cannot give. `tshark` dissects the exchange to confirm. +- **Half of that has changed, and it made `pnpm test:nfs:mount` go red + (2026-07-31).** `nfs` and `nfs4` are now in `/proc/filesystems` — the client + module is loaded — while there is still **no `mount.nfs` binary anywhere**. So + `nfsClientProbe()` now reports usable and the Tier-2 suite stops skipping, but + every mount fails at `mount(8)`: `fsconfig() failed: NFS: Server address does +not match proto= option`, exit 32, 4 failed. Reproduced with + `pnpm test:nfs:mount` on a tree carrying no NFS changes, so it is the host and + not a regression. Two things follow: the probe's kernel test is not sufficient + on its own — a loaded module without the userland helper is a state it does not + distinguish — and `pnpm test:root` is red on this host until one of the two is + addressed. ## 9P mounting (verified 2026-07-29, this Linux host) @@ -408,6 +419,114 @@ until something asks: and falls back to **size plus modification time**. `--size-only` is the comparison with no hash in it at all. +## davfs2, the WebDAV mount client (installed 2026-07-31, this Linux host) + +`test/webdav/mount.test.ts` is the one Tier-2 column that needs it. **`pnpm +test:webdav:mount` passes**: 5 passed, 2 skipped, ~0.2 s of tests. + +- **`/usr/sbin/mount.davfs`, davfs2 1.7.3**, from `sudo dnf install davfs2` + (pulls `neon` 0.37.1 and `libntlm`; creates the `davfs2` user and group). It + needs **no kernel module of its own**: davfs2 mounts through **FUSE**, which + this host already has, and the mount table line names the share as its source: + + ``` + http://127.0.0.1:37487/ /tmp/…/mnt fuse rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other,max_read=65536 0 0 + ``` + +- **It needs root, and that is davfs2's rule rather than the kernel's.** + `mount.davfs` is `-rwsr-xr-x root root`, but an unprivileged caller is refused + with `no entry for found in /etc/fstab` — the unprivileged route wants + an `/etc/fstab` line plus `davfs2` group membership, neither of which a test + may arrange. So this column is sudo-only, like 9P. Nothing about the _server_ + needs privilege: it is an ordinary user's process on an ordinary TCP socket. +- **The configuration goes in a file passed with `-o conf=`**, so + `/etc/davfs2/davfs2.conf` is neither read nor written — the same + "configuration entirely outside the developer's own" trick + `test/webdav/oracle.test.ts` plays with `RCLONE_CONFIG=""`. The mount line the + suite uses, verbatim: + + ```sh + mount.davfs http://127.0.0.1:PORT/ /mnt/point -o conf=…/davfs2.conf,rw,uid=0,gid=0 + ``` + +- **davfs2 writes either way, and locks when it is offered locks.** Both halves + were measured here by shadowing `session.handleRequest` and counting methods, + and they are worth keeping apart: + - Against **class 1** (`DAV: 1, 3`, which this server sent before locking + landed) it prints `mount.davfs: warning: the server does not support locks` + and mounts read-write anyway — **not one `LOCK` was sent**. So the class-2 + gap cost this client nothing; macOS's `mount_webdav` is the client that + insists, and it is a different client. + - Against **class 2** (`DAV: 1, 2, 3`) it takes a lock per write and releases + it: `echo > f && cp f g` over the mount is `OPTIONS 1, PROPFIND 2, HEAD 2, +LOCK 2, PUT 2, UNLOCK 2`. The warning is gone. That is what makes + `test/webdav/mount.test.ts` a check on the _locking_ path rather than only + on the class-1 one, and `use_locks` is left at its default of on for exactly + that reason. +- **HTTP Basic works through the mount.** `-o username=ada` with the password on + the helper's stdin mounts; a wrong password fails the mount outright with + `Could not authenticate to server: rejected Basic challenge`, which is the + server's `401` being read rather than the client guessing. +- **`umount` hangs in this container, and `umount -i` does not.** Plain `umount` + runs `/sbin/umount.davfs`, which unmounts and _then_ polls until the + `mount.davfs` daemon leaves the process table. The daemon exits immediately, + but this container's pid 1 is not an init and never reaps it, so it sits there + `Z` forever and the helper waits forever with it. `umount -i` skips the helper, + issues the same `umount(2)`, and returns in ~9 ms with the table clear. This is + a container artefact, not a davfs2 defect — but the suite uses `-i` + unconditionally, because the helper's wait buys nothing it needs. +- **No wedge risk, unlike FUSE.** With the server killed under a live mount, + metadata is still answered from cache, a read that needs the network fails + immediately with `EAGAIN` (`Resource temporarily unavailable`) rather than + parking, and a plain `umount -i` still returns 0. Nothing here needs + `umount -f`, `fusectl` or an abort. +- **The one leak `umount -i` does leave is the cache.** davfs2 keeps a per-mount + cache at `/var/cache/davfs2/++`, and `umount.davfs` is what would remove it. The suite removes + its own by matching the `mkdtemp` basename. `cache_dir` in the config **cannot** + redirect it into the test's temp tree: the daemon drops to uid 998 (`davfs2`) + and cannot traverse a `0700` `mkdtemp` chain — `mount.davfs: can't open cache +directory …`. +- **Timings, this host:** mount ~20 ms, first listing ~1 ms, `umount -i` ~9 ms. + A write returns as soon as it is cached and the `PUT` follows on `close(2)`, + landing on the driver **1–3 ms** later with `delay_upload 0` (the default is a + ten-second delay). That gap is why every driver-side assertion is a bounded + poll. +- **Its cache also means a read-back proves nothing by itself**: a file this + mount just wrote is served from cache and the server sees no `GET` at all (one + `GET` and one `HEAD` across an entire exploratory workload). The read path is + only real for files written to the driver **before** the mount existed, which + is how the suite tests it. +- **What davfs2 actually sends.** One exploratory workload — trees, appends, + truncate, rename-over, `cp -r`, `rm -rf`, 16 MiB both ways, 40-file + directories, `df`, `touch`, `chmod`: + + ``` + PUT=75 PROPFIND=19 MKCOL=17 DELETE=10 MOVE=2 PROPPATCH=1 OPTIONS=1 HEAD=1 GET=1 + ``` + + **Every reply was 2xx** — 201/204 for `PUT`, 207 for `PROPFIND`, 201 for + `MKCOL`, 204 for `DELETE` — so this client found no fault in the server. + +- **Verified through the mount, all passing**: read/write/`mkdir`/rename/ + `unlink`/`rmdir`; 1 MiB and 16 MiB files byte-exact in both directions; a + positional read at a 512 KiB offset; append, `truncate`, rename-over-existing, + `cp -r`, `rm -rf`; a 4-deep tree; 40-entry listings through `readdir` and + through `ls` in a separate process; `ENOENT`/`ENOTDIR`/`ENOTEMPTY`/`EEXIST`; + `df` (statfs, answered from the RFC 4331 quota properties); and names carrying + a space, `+ # ? % & ' ; @ = [ ] ~` and non-ASCII (`naïve`, `¥`, `日本語`) — + every one round-tripped byte-exact, so the target↔`href` escaping holds against + paths a VFS chose. +- **Three things WebDAV cannot carry, and they are not bugs.** `symlink` is + `ENOSYS` and `link` is `EPERM` (no method exists for either); `chmod` and + `utimes` succeed through the mount but never reach the driver — davfs2 keeps + the mode locally, and `touch` produces a `PROPPATCH` that this server answers + `207` with a `403` propstat inside, exactly as `src/webdav/session.ts` says it + will for a server with no dead properties. +- **davfs2 shows a synthetic `lost+found` at the root of the mount** that does + not exist on the driver (its cache's orphan directory). Anything asserting on a + root listing has to allow for it — the suite works in subdirectories instead. + ## macOS host (verified 2026-07-28) macOS 26.6 (build 25G72), arm64 (`VirtualMac2,1`), Node v24.18.0, passwordless diff --git a/.agents/invariants.md b/.agents/invariants.md index ed2eede..b877989 100644 --- a/.agents/invariants.md +++ b/.agents/invariants.md @@ -48,7 +48,13 @@ no cast. **The errno table is transcribed once**, in `src/errors.ts`. The addon reports a raw positive `errno` and lets `src/fuse/native.ts` name it, rather than carrying a second -copy of the table in another language where the two would drift. +copy of the table in another language where the two would drift. The same argument +covers a wire format two transports share: RFC 9110's `HTTP-date`, `Range` and `ETag` +spellings live in `src/http.ts` — with the two entity-tag comparison functions and the +§13.2.2 conditional-request rules built on them — and `src/s3/protocol.ts` re-exports +them under its own names while `src/webdav/` imports them directly. One transcription, +two HTTP transports; `evaluateConditionals` is wrapped rather than re-exported only +because SigV4 makes the S3 gateway keep its headers as a signed list. ## Wire protocols @@ -56,9 +62,11 @@ copy of the table in another language where the two would drift. FUSE constants come from the kernel's `include/uapi/linux/fuse.h`; NFS constants come from RFC 1813/5531/4506 and, for v4.1, RFC 8881 with RFC 5662 for the XDR it does not spell out; 9P constants come from the kernel's `include/net/9p/9p.h` (both header -sources pinned at tag v6.12) with diod's `protocol.md` as the prose reference; the -`fusermount3` handshake and the Node-API declarations come from libfuse's and Node's -own sources, both named where they are used. +sources pinned at tag v6.12) with diod's `protocol.md` as the prose reference; WebDAV's methods, +statuses, properties and precondition names come from RFC 4918, with RFC 4331 for the +quota pair and RFC 9110 for the HTTP underneath; the `fusermount3` handshake and the +Node-API declarations come from libfuse's and Node's own sources, both named where +they are used. **The wire's `O_*` and a driver's `O_*` are different namespaces.** `fuse_open_in.flags` is the Linux kernel's; `FsDriver` is a subset of @@ -77,7 +85,9 @@ NFSv4.1, since COMPOUND is the one procedure everything else travels inside. one, a thrown value becomes a negative errno on FUSE (unknown → `EIO`), a positive Linux errno in an `Rlerror` on 9P, an `nfsstat3` on NFSv3, a legal `nfsstat4` on NFSv4.1 (an escaped `XdrError` → `NFS4ERR_BADXDR`, anything else → -`NFS4ERR_SERVERFAULT`) and one S3 XML error body on the gateway, and a dev-mode +`NFS4ERR_SERVERFAULT`), one S3 XML error body on the gateway, and one HTTP status — +mapped from the errno by `src/webdav/constants.ts`'s table, with a `DAV:` `` +document when there is a §16 condition to carry — on WebDAV, and a dev-mode assertion tracks it per request id. A retried `(session, slot, sequence)` on v4.1 answers from the slot's reply cache and re-runs nothing. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 829de96..9227220 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -37,8 +37,10 @@ area whose code it changes, not the area that motivated it. - **`mountx/auto` never falls back after a mount failure**, and never probes when a transport is named. The probe decides from host facts, once — a silent second attempt would hand back different semantics than the error nobody saw. -- **The S3 gateway stays out of `mountx/auto`.** Auto's whole contract is a - mountpoint and that transport never produces one. +- **The two HTTP transports stay out of `mountx/auto`.** Auto's whole contract + is a mountpoint, and neither `mountx/s3` nor `mountx/webdav` produces one — + even though a WebDAV _client_ (`davfs2`, `mount_webdav`) can, which is a fact + about the host's tooling rather than something this package does. - **NLM byte-range locking is out of scope**, hence NFS mounts use `nolock`. - **Per-call caller credentials are not going into `FsDriver`.** The shape would be a `mountx.*` extension carrying the caller's uid/gid/groups into each @@ -239,6 +241,37 @@ area whose code it changes, not the area that motivated it. attribute's "deliberately absent, it is the OPEN step's call" note predates the OPEN step making that call. +## WebDAV + +- **Requested properties lose their namespace.** `src/s3/xml.ts`'s parser + reports an element's local name and drops its prefix, which is right for the + grammar and wrong for a property in a namespace other than `DAV:` — Finder's + and Office's `Win32*` properties come back as bare local names in `DAV:`, + inside the `404` propstat. Bounded to properties this server does not have, + and fixing it means teaching that parser to track `xmlns` bindings, which is a + change to a module the S3 gateway depends on. +- **No dead properties.** `PROPPATCH` writes `getlastmodified` (through + `driver.utimes()`, on a driver declaring `times`) and refuses everything else + with `403 cannot-modify-protected-property`, which is truthful for a server + whose other properties are all live — and is also what makes a client that + sets `Win32LastModifiedTime` (Finder, Explorer) report a failure it did not + expect. Storing one needs a place to put it that the driver interface does not + have: a sidecar file would show up in every listing, so this waits for a + driver-level property store rather than for a WebDAV-level workaround. +- **RFC 9110's conditional requests are honoured on `GET`, `HEAD` and `PUT` + only.** `DELETE`, `COPY` and `MOVE` ignore `If-Match` and the other three; + RFC 4918's own `If` header is what a WebDAV client uses on those, and it _is_ + enforced. Extending the four to the rest is a small change with one real + question in it — which resource a `COPY`/`MOVE` conditional names, the source + or the destination — and RFC 9110 does not answer it for a method it does not + define. +- **The two HTTP servers duplicate their transport mechanics.** + `src/webdav/server.ts` and `src/s3/server.ts` track connections, drain on + `close()` and write a streaming reply the same way, deliberately not shared + yet: the bind refusal's wording, the fallback error reply and the + authentication are each transport's own. If a third HTTP-shaped transport + appears, this is the duplication to remove first. + ## Platforms - **AppleDouble sidecars are undocumented in `docs/`.** macOS tags every new @@ -256,6 +289,12 @@ area whose code it changes, not the area that motivated it. into `kNetFSAlternatePortKey` and then never reads it — it goes to port 111 and fails `ECONNREFUSED`, fatal for a server with no portmapper. Worth it only if someone actually needs the volume to appear where Finder puts one. -- **WebDAV and Windows support.** Not designed against; WebDAV is the - unprivileged, zero-native-code path for macOS/Windows. Windows also has no - `mount(8)`, so it stays out of the NFS transport's platform switch. +- **Windows is still not designed against.** `mountx/webdav` is the + unprivileged, zero-native-code path that could reach it — the Windows + redirector mounts a WebDAV share with no kernel module and no root, and the + class-2 locking it wants before it will write is now there — but nothing here + has been run on Windows, so "the redirector should be happy" is a prediction + from the RFC and not a verified fact. The same goes for macOS's + `mount_webdav`, which mounts a class-1 share read-only: this host is Linux. + Windows also has no `mount(8)`, so it stays out of the NFS transport's + platform switch either way. diff --git a/.agents/testing.md b/.agents/testing.md index a9807ba..72baaa3 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -123,6 +123,36 @@ test:9p:mount` / `pnpm test:root`) — 9P has no unprivileged route on any host, `oracle.test.ts` — a real `rclone`/`curl` against the gateway, gated on `command -v rclone`/`curl` and needing no root, so it runs as part of `pnpm test` and skips clean when either binary is absent. +- `test/webdav/` — Tier 0/1, all of it socket-optional: `protocol.test.ts` (the + three request grammars, the `If` header's disjunction-of-conjunctions, the + `multistatus`/`error`/lock documents, and the target↔`href` mapping round-tripped + — the security-relevant half, since a name is the only thing that decides which + resource a request reaches), `locks.test.ts` (the lock table alone, with `now` a + number the test moves and `newToken` a counter: §9.10.5's compatibility table, + both scopes, the lease, and §6.1's rule that a lock dies with its root), + `session.test.ts` (in-process against the memory driver, no sockets: RFC 4918's + per-method semantics, `LOCK`/`UNLOCK` and what a write lock refuses — `412`, + `423` and the `207` that names a locked member — RFC 9110's conditionals, the + `207` partial-failure shapes for `DELETE` and `COPY`, Basic auth, and the + one-reply discipline), and `server.test.ts` (real sockets driven with `fetch` + plus one raw one: the bind gate, keep-alive, an unread body drained, a short body + taking the connection with it, and an abandoned download releasing its handle), + plus `oracle.test.ts` — a real `rclone` and `curl` against the server, gated on + `command -v` and needing no root, so it runs as part of `pnpm test` and skips + clean when either binary is absent. That is the file that catches a symmetric + misreading of RFC 4918 — including the whole class-2 round trip, where curl takes + a lock on an unmapped URL, is refused `423` for an untokened `PUT`, and gets + through with an `If` header. **No conformance column yet** — see Known gaps. +- `test/webdav/mount.test.ts` — Tier 2, and the only test in the package that puts a + **kernel** in front of this server: `mount.davfs` (davfs2, over FUSE) mounts the + share and the workload is ordinary syscalls, not WebDAV. Gated on the in-file + `davfsClientProbe()` — Linux, `mount.davfs`, `/dev/fuse`, and root, since davfs2 + refuses an unprivileged caller without an `/etc/fstab` entry — so it is sudo only + (`pnpm test:webdav:mount` / `pnpm test:root`) and skips clean everywhere else. Two + facts shape it, both in `.agents/environment.md`: davfs2 caches, so a driver-side + check is a bounded poll and the read path is proved by seeding the driver _before_ + mounting; and teardown is `umount -i`, because the `umount.davfs` helper waits on a + daemon that a container with no reaping init never reaps. - `test/auto.test.ts` — Tier 0 for `mountx/auto`: the preference order and the ruled-out reasons, answered for darwin and win32 from any host via the `platform` override. `test/auto-mount.test.ts` — Tier 2, whichever transport this host chose. @@ -134,6 +164,14 @@ rclone`/`curl` and needing no root, so it runs as part of `pnpm test` and skips ## Known gaps +- **No WebDAV conformance column.** `test/webdav/` pins the protocol thoroughly but + does not run the shared suite, because that needs an `FsDriver` built over the + WebDAV session the way `test/s3/client.ts` is over the S3 one — and a WebDAV client + can offer no `handles`, no `symlinks`, no `permissions` and no `truncate`, so most + of what the column would report is already known from the protocol. It is worth + writing anyway, for the same reason the S3 column was: the rows nobody predicted + are the point. Until then `pnpm matrix` has five columns, not six. + - **No real-mount NFSv4.1 column.** Tier-2 `test/nfs/mount.test.ts` is v3-only; the dev host has no `mount.nfs` to write a v4.1 one against. The protocol is not unwitnessed, though: a VM guest supplies the missing client, and two of them have diff --git a/AGENTS.md b/AGENTS.md index 5b68a65..7722863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # mountx Mount a JavaScript filesystem: one driver interface (a subset of `node:fs/promises`), -multiple transports — FUSE, 9P, NFS (v3 and v4.1) — plus an S3 gateway (`mountx/s3`) -that serves the same driver to an S3 client instead of mounting it, deliberately -outside `mountx/auto`. +multiple transports — FUSE, 9P, NFS (v3 and v4.1) — plus two that serve the same +driver over HTTP instead of mounting it, an S3 gateway (`mountx/s3`) and a WebDAV +server (`mountx/webdav`), both deliberately outside `mountx/auto`. **Conventions:** pure JS/TS, zero runtime deps, pure-JS-first. Single package with subpath exports. Small conventional commits to `main`, `pnpm test` green before each @@ -28,12 +28,14 @@ any rule below that looks removable. | `src/harness.ts` | `createLoopback(driver)` — what driver authors test against | | `src/lock.ts` | `PathLock`, taken by `RENAME` on every transport | | `src/subtree.ts` | `remapSubtree()` — the rename rewrite all three handle tables share (internal) | +| `src/http.ts` | RFC 9110's `HTTP-date`, `Range` and `ETag` quoting — shared by the two HTTP transports | | `src/auto.ts` | `mountx/auto`: probe, then FUSE → 9P → NFS, each via `await import()` | | `src/drivers/` | `memory` (the only `mountx.mknod` implementation), `node-fs`, `unstorage`, `handle.ts` | | `src/fuse/` | `mountx/fuse` — protocol 7.41, root and `fusermount3` mount paths, `exec.ts` (shared spawn/`Deadline`) | | `src/9p/` | `mountx/9p` — 9P2000.L, `trans=unix` by default, one session per connection | | `src/nfs/` | `mountx/nfs` — a version router over `v3/` (RFC 1813 + MOUNT) and `v4/` (NFSv4.1); Linux and macOS | | `src/s3/` | `mountx/s3` — SigV4 gateway over HTTP, path-style, one bucket per driver | +| `src/webdav/` | `mountx/webdav` — RFC 4918 classes 1, 2 and 3 over HTTP: every method, write locks, `If` | | `src/cli/` | the `mountx` bin — a demo and test bench that mounts this package's README | | `native/` | the Zig Node-API addon and its generated embed (`prebuilt.mjs`) | | `test/` | Tier 0/1/2 suites and the shared conformance suite — see `.agents/testing.md` | diff --git a/README.md b/README.md index 2b950c0..7f1f199 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ npx mountx - [Troubleshooting](https://mountx.vercel.app/guide/troubleshooting) — the things that will bite you, and how to recover. - [Transports](https://mountx.vercel.app/transports) — FUSE, 9P and NFS, what each costs, and how to pin one. - [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. +- [WebDAV](https://mountx.vercel.app/transports/webdav) — serve a driver over RFC 4918 to `rclone`, `curl` or `davfs2`, no mount involved either. - [Reference](https://mountx.vercel.app/reference) — every entry point, option and type. ## Development diff --git a/build.config.ts b/build.config.ts index c4f55f9..806813e 100644 --- a/build.config.ts +++ b/build.config.ts @@ -12,6 +12,7 @@ export default defineBuildConfig({ "./src/nfs/index.ts", "./src/9p/index.ts", "./src/s3/index.ts", + "./src/webdav/index.ts", "./src/drivers/memory.ts", "./src/drivers/node-fs.ts", "./src/drivers/unstorage.ts", diff --git a/docs/2.transports/0.index.md b/docs/2.transports/0.index.md index d6c1861..c96e994 100644 --- a/docs/2.transports/0.index.md +++ b/docs/2.transports/0.index.md @@ -5,9 +5,9 @@ title: Overview # Transports -**One driver interface, four ways of getting it in front of a client — three of them a kernel, one of them not.** +**One driver interface, several ways of getting it in front of a client — some of them a kernel, some of them not.** -A transport is everything between the driver you wrote and whatever is going to read it: the protocol codec, the session that turns messages into driver calls, and (for FUSE, 9P and NFSv3) the piece that attaches the result to a directory. `mountx/auto` picks among those three mount transports for you; all three can be [pinned](#pinning-a-transport) directly. The fourth, [S3](/transports/s3), is not a mount at all — it serves the driver to an S3 client over HTTP instead, which is exactly why `auto` does not choose it. +A transport is everything between the driver you wrote and whatever is going to read it: the protocol codec, the session that turns messages into driver calls, and (for FUSE, 9P and NFSv3) the piece that attaches the result to a directory. `mountx/auto` picks among those three mount transports for you; all three can be [pinned](#pinning-a-transport) directly. The other two, [S3](/transports/s3) and [WebDAV](/transports/webdav), are not mounts at all — they serve the driver to an HTTP client instead, which is exactly why `auto` does not choose either. ```mermaid graph TB @@ -21,8 +21,11 @@ graph TB knfs["any NFSv3 or NFSv4.1 client
over TCP"] s3["mountx/s3
S3 REST + SigV4"] ks3["any S3 client
over HTTP"] + dav["mountx/webdav
RFC 4918 classes 1, 2, 3"] + kdav["any WebDAV client
over HTTP"] driver --> auto driver --> s3 + driver --> dav auto --> fuse auto --> p9 auto --> nfs @@ -30,6 +33,7 @@ graph TB p9 --> kv9fs nfs --> knfs s3 --> ks3 + dav --> kdav ``` ## What it is choosing between @@ -103,9 +107,11 @@ It is optional, lazy, and never on the root path: mounting as root opens `/dev/f It also ships as compressed base64 inside a JavaScript module rather than as a `.node` file — a binary is loaded by path, and a path is the one thing a bundle does not have. The loader extracts it to a private temporary directory, `dlopen`s it, and deletes it again. So it bundles: nothing to configure, nothing to mark external, no sibling file to copy into your output. -## The fourth transport is not a mount +## The two transports that are not mounts -[`mountx/s3`](/transports/s3) serves the same `FsDriver` to an S3 client instead — `rclone`, the AWS CLI, an SDK, a presigned URL — over plain HTTP. Nothing about it produces a mountpoint, so it sits outside everything above: `probeTransports()` never mentions it, `mountx/auto` never picks it, and pinning it is the only way to reach it. Reach for it when what is in front of the driver is an S3 client rather than `ls` and `cat`. +[`mountx/s3`](/transports/s3) serves the same `FsDriver` to an S3 client — `rclone`, the AWS CLI, an SDK, a presigned URL — and [`mountx/webdav`](/transports/webdav) serves it to a WebDAV client, over plain HTTP in both cases. Neither produces a mountpoint, so both sit outside everything above: `probeTransports()` never mentions them, `mountx/auto` never picks them, and pinning is the only way to reach either. Reach for one when what is in front of the driver is an HTTP client rather than `ls` and `cat`. + +Between the two: **S3** is for object-storage clients and has no directories, no rename and no partial write, but it does have multipart uploads and presigned URLs. **WebDAV** is a filesystem protocol — collections, `MOVE` on top of the driver's `rename`, byte ranges — and it is the one a kernel can mount without root or native code (`davfs2` on Linux, `mount_webdav` on macOS, the Windows redirector), which makes it the portable escape hatch when none of the three mount transports fit. ## Next @@ -114,3 +120,4 @@ It also ships as compressed base64 inside a JavaScript module rather than as a ` - [9P2000.L](/transports/9p) — stateful and root-only, for a Linux host or a VM guest. - [NFS](/transports/nfs) — both versions, including serving without mounting at all. - [S3](/transports/s3) — the gateway transport, and why it stays out of `auto`. +- [WebDAV](/transports/webdav) — RFC 4918 classes 1, 2 and 3, and the unprivileged way to get a mountpoint anyway. diff --git a/docs/2.transports/6.webdav.md b/docs/2.transports/6.webdav.md new file mode 100644 index 0000000..0019671 --- /dev/null +++ b/docs/2.transports/6.webdav.md @@ -0,0 +1,318 @@ +--- +icon: lucide:folder-tree +title: WebDAV +--- + +# WebDAV + +**The other transport that is not a mount: serve a driver over HTTP to anything that speaks WebDAV.** + +`mountx/webdav` implements [RFC 4918](https://www.rfc-editor.org/rfc/rfc4918) **classes 1, 2 and 3** — every method the specification defines, write locking included — over any `FsDriver`. `rclone`, `curl`, `cadaver`, `davfs2` and a file manager's "connect to server" all talk to it, and nothing here produces a mountpoint, which is why (like [S3](/transports/s3)) it sits outside [`mountx/auto`](/transports/auto). + +```ts +import { createWebdavServer } from "mountx/webdav"; +import { createMemoryDriver } from "mountx/drivers/memory"; + +await using server = await createWebdavServer(createMemoryDriver()).listen(); +server.url; // http://127.0.0.1: + +// rclone ls :webdav: --webdav-url $server.url --webdav-vendor other +``` + +## Quick start + +### curl + +Every method is an ordinary HTTP request, so the protocol is reachable with nothing but `curl`: + +```sh +curl -X MKCOL "$URL/notes" +curl -T ./hello.txt "$URL/notes/hello.txt" +curl -X PROPFIND -H 'Depth: 1' "$URL/notes" # a 207 multistatus listing +curl "$URL/notes/hello.txt" # the bytes +curl -X MOVE -H "Destination: $URL/notes/renamed.txt" "$URL/notes/hello.txt" +curl -X DELETE "$URL/notes" +``` + +### rclone + +```ini +# rclone.conf, or the equivalent RCLONE_CONFIG_MX_* environment variables +[mx] +type = webdav +url = http://127.0.0.1:PORT +vendor = other +# user / pass only if the server was given credentials +``` + +```sh +rclone sync ./notes mx:notes +rclone lsjson -R mx: +``` + +`vendor = other` is the one that matters: the `owncloud` and `nextcloud` vendors ask for checksums and chunked-upload endpoints this server does not have. + +### Mounting it + +A WebDAV share is mountable without any of this package's mount transports, and without root or native code — which is the reason this transport exists: + +```sh +# Linux +sudo mount -t davfs http://127.0.0.1:PORT /mnt/point + +# macOS +mount_webdav -S http://127.0.0.1:PORT /Volumes/mountx +``` + +::note +**Verified here, and predicted there.** Every client that speaks the protocol directly — `rclone`, `curl`, `cadaver`, a browser, `davfs2` — reads and writes normally, and the class-2 round trip is exercised against real `curl` in this repository's test suite. macOS's `mount_webdav` mounts a class-1 share **read-only** and the Windows redirector refuses to write to one; both want the locking that is now [here](#locking), so both are _expected_ to write to this share. Neither has been run against it: the machine this is developed and tested on is Linux, and an untested platform claim is not one this documentation will make. +:: + +## Who may connect + +The same rule, with the same literal address check, as [the S3 gateway](/transports/s3#who-may-connect): + +- **No `credentials`** — every request is served unauthenticated, so the bind is **loopback-only**: a non-loopback `host` — `0.0.0.0` and `::` included, since they bind every interface — is refused outright, before a socket opens, with a named `WebdavBindError`. +- **With `{ username, password }`** — every request is authenticated with HTTP Basic (RFC 7617), and any `host` is allowed. + +Basic sends a recoverable password on every request, which is WebDAV's own default and is why every client implements it. Over anything but a trusted network it wants TLS in front of it; this server speaks plain HTTP and does not pretend otherwise. + +## Semantics + +### What each method answers + +| method | | +| ------------ | ----------------------------------------------------------------------------------------------------------------- | +| `OPTIONS` | `DAV: 1, 2, 3`, `Allow`, `MS-Author-Via: DAV`. Answered without touching the driver, for any target | +| `GET`/`HEAD` | the bytes, with `ETag`, `Last-Modified` and a single `Range` (`206`, or `416` when unsatisfiable) | +| `PUT` | `201` when it created, `204` when it replaced. `Content-Range` is refused (`400`) | +| `DELETE` | `204`, or a `207` naming what would not go. `Depth` on a collection must be `infinity` | +| `MKCOL` | `201`. A request body is `415`; an existing resource is `405` | +| `COPY` | `Depth` `0` or `infinity`; `201`/`204`, or `207` for a tree that only partly copied | +| `MOVE` | `Depth: infinity` only, and a `rename` underneath — so it is atomic when the driver's is | +| `PROPFIND` | `Depth: 0` or `1`, `207` multistatus. `infinity` is `403 propfind-finite-depth` | +| `PROPPATCH` | `207` per property: `200` for `getlastmodified`, `403 cannot-modify-protected-property` for the rest | +| `LOCK` | `200` on a resource, `201` on a URL with nothing at it, `423` when a lock is in the way — see [locking](#locking) | +| `UNLOCK` | `204`. `400` with no `Lock-Token`, `409 lock-token-matches-request-uri` when the token names no lock here | + +Anything else — `REPORT`, `PATCH`, `SEARCH` — is `405` with an `Allow` that lists what is really there. + +Two of those differ from what a plain HTTP server would answer, and both are RFC 4918 being deliberate: + +- **`PUT` under a missing parent is `409 Conflict`, never `404`.** Intermediate collections are not created for you (§9.7.1); `MKCOL` is the client's job. This is the opposite of the S3 gateway, where a prefix is conjured because S3 has no directories to create. +- **`GET` of a collection is `405`.** A collection has no body in RFC 4918. The HTML index other servers answer with is a user interface; `PROPFIND` is the protocol's own way to list one. + +### Properties are all live + +Every property is derived from a single `stat` or from the lock table, and none are stored: `creationdate`, `displayname`, `getcontentlength`, `getcontenttype`, `getetag`, `getlastmodified`, `resourcetype`, `supportedlock` (the two lock entries this server grants) and `lockdiscovery` (the locks that really cover the resource, the depth-infinity one rooted above it included). RFC 4331's `quota-available-bytes` and `quota-used-bytes` come from `statfs()` when the driver has one, and only when a request names them, which is what RFC 4331 §3 requires. + +`PROPPATCH` can store exactly one of them: **`getlastmodified`, through `driver.utimes()`**, and only on a driver that declares the [`times` capability](/reference/capabilities) — otherwise the property really is protected here, and answering `200` would be storing nothing. A value that is not an `HTTP-date` is `409` (§9.2.1: "the client has provided a value whose semantics are not appropriate for the property"). Everything else is `403 cannot-modify-protected-property`. + +There are no **dead** properties, and `PROPPATCH` says so rather than accepting one and forgetting it: a driver stores bytes and inode metadata, and the only place to keep arbitrary XML would be a sidecar file that then shows up in every listing. + +The method is atomic, which §9.2 requires: instructions are processed in document order and "either all be executed or none executed", so one property that cannot be set makes every property that could have been `424 Failed Dependency` and writes nothing. + +`getcontentlength` and `getetag` are answered for non-collections only. An `allprop` request simply leaves them out for a collection; a request that _names_ one gets a `404` propstat for it, which is the difference between "what have you got" and "have you got this". + +### ETags are derived + +The first 32 hex characters of `sha256("dev:ino:size:mtimeMs")` — the same inputs as [the S3 gateway's](/transports/s3#etags-are-derived-not-md5), without its multipart-shaped `-1` suffix. Never a hash of the bytes: answering a `PROPFIND` must not mean reading every resource it describes. Two writes inside one millisecond that leave the size unchanged are indistinguishable to it, which is exactly the resolution `getlastmodified` has. + +### Paths, hrefs and the encoded separator + +A request target is percent-decoded **one segment at a time** and then normalized, with `..` clamping at the root — so there is no traversal out of the driver by construction. A segment that decodes to something containing a `/` is refused with `400` rather than read as a separator: an S3 key may contain a slash, a POSIX name may not, so `%2F` names a resource this server does not have. + +Going the other way, every `href` is percent-encoded per segment, and a collection's ends with `/`. + +### Symbolic links are followed for bytes, never walked + +WebDAV has no way to name a link, so a link is the resource it points at: `GET`, `PROPFIND` and every property follow one. The two **recursive** methods deliberately do not, because following a link there is destructive rather than convenient — `DELETE` removes the link itself (never the contents of what it points at), and `COPY` reports a link to a collection with `403` in its `207` rather than descending into it, since a link back to any ancestor makes the copy revisit a subtree it is still writing into. A link to a _file_ is followed by `COPY` and its bytes are copied. + +### The write-in-place caveat + +`PUT` writes in place: there is no temporary file and no rename, because the driver interface has no atomic-create primitive to build one on. What is guaranteed is the _first_ byte — the destination is not opened, so an existing resource is not truncated and a new one is not created, until a byte of the body has arrived. A `PUT` refused at or before then leaves the resource exactly as it was; one that dies mid-body leaves what had been written. + +A `COPY` of a tree is not a transaction either. What succeeded stays, and the reply is a `207` naming each resource that failed — which is why the status is per-resource rather than one code that would describe neither half. + +## Locking + +Class 2 is RFC 4918's write locks (§6, §7), and all of it is here: exclusive and shared, `Depth: 0` and `Depth: infinity`, leases that lapse, refresh, and the `If` header that makes a lock mean something. + +```sh +# Take an exclusive lock on a resource that does not exist yet, reserving the name. +curl -X LOCK -H 'Timeout: Second-300' --data-binary ' + + + + ada +' "$URL/notes/draft.txt" # 201 Created, Lock-Token: + +curl -T ./draft.txt "$URL/notes/draft.txt" # 423 Locked +curl -H "If: ()" -T ./draft.txt "$URL/notes/draft.txt" # 204 +curl -X UNLOCK -H "Lock-Token: " "$URL/notes/draft.txt" # 204 +``` + +### What a lock covers + +A **depth-0** lock covers the resource it names. On a collection that means the collection and its _membership_ — creating, removing or renaming an internal member needs the token, while the members' own contents do not. + +A **depth-infinity** lock covers the resource and everything under it, now and later: a resource created inside a locked collection is locked by it, and one moved out of it is not. There is no per-member bookkeeping, because the scope is a prefix of a path rather than a list. + +Two locks whose scopes overlap conflict unless **both** are shared, which is §9.10.5's compatibility table. A conflict is `423` with `no-conflicting-lock`, naming the root of the lock in the way so a client need not go looking for it with a `PROPFIND`. + +### Tokens and leases + +A token is a `urn:uuid:` URI this server mints — the form §6.5 encourages — returned in the `Lock-Token` response header and in the `lockdiscovery` body. It is the whole of ownership: there is one principal here at most (`credentials`), so holding the token is what a request proves, and `UNLOCK` needs nothing else. + +Leases are the server's to choose (§6.6). A `LOCK` that asks for nothing gets **10 minutes**; `Timeout: Infinite` gets the **1 hour** cap; anything in between is honoured as asked. The granted value always goes back in the reply's `timeout` element, so a client never has to guess what it got, and a `LOCK` with no body and an `If` header naming the token restarts the counter (§9.10.2). There is no timer anywhere: a lock stops existing the moment anything looks at the table past its deadline, which is exactly what §6.6 describes and what clients are told to expect. + +Nothing is unbreakable, deliberately: this server has no administrative interface, so a lock nobody unlocks would be a resource nobody can write for as long as its lease runs. + +### `LOCK` on a URL with nothing at it + +§7.3's **locked empty resource**: the request creates a real, empty, readable file and answers `201 Created`. It behaves as any other resource — it can be read, copied, moved and deleted, and it appears in its parent's listing — and it **outlives the lock**, because "clients must therefore be responsible for cleaning up their own mess". RFC 2518's lock-null resources are the alternative §7.3 permits, and they are not implemented: a resource that is neither present nor absent has no representation in a driver that stores files. + +### A lock never follows its resource + +§7.6 is explicit, and it surprises people: a `MOVE` does **not** carry the lock along. Instead §6.1 deletes any lock whose root a request unmapped, so moving or deleting a lock root destroys that lock, and the resource arrives at its destination unlocked — except for whatever depth-infinity lock already covers the destination, which picks it up for free. + +### Proving you hold one: the `If` header + +`If` (§10.4) is a disjunction of conjunctions: `(a b) (c)` is true when `a` and `b` both hold, or `c` does. Conditions are lock tokens (``) and entity tags (`["..."]`), each optionally negated with `Not`, and a list can be _tagged_ with the resource it is about — which is how a `COPY` submits the token for its destination rather than for the request URI: + +``` +If: () +``` + +The header does two separate jobs, and RFC 4918 insists they stay separate. It is a **precondition** — if every list is false, the request is `412` — and it is a **submission**: every token in it counts as submitted whether or not the list carrying it was true. That is what makes the `(Not )` idiom work: append it and the header is always true, while the tokens beside it are still submitted. + +Which refusal comes back says which of the two failed: + +| status | | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `412 Precondition Failed` | the header was there and no state list was true — your copy is stale, re-read the resource | +| `423 Locked` + `lock-token-submitted` | the request would change a locked resource and did not carry its token; the `href`s name the lock roots | +| `207` carrying `423` | the lock is on a _member_ of the tree you named, not on the resource you named; nothing was changed | + +`GET`, `HEAD`, `PROPFIND` and `OPTIONS` are never refused by a lock — §7 is explicit that they "function independently of a write lock" — but an `If` header on one of them is still a precondition. + +Which methods owe which token is §7.5's: a `COPY` needs the destination's only (the source is not modified), a `MOVE` needs both ends', and `PUT`, `MKCOL`, `DELETE`, `PROPPATCH` and the `LOCK` that creates an empty resource need the resource's own — plus its parent's, when they add or remove an internal member of a locked collection. + +## Conditional requests + +RFC 9110's four — `If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since` — are honoured on `GET`, `HEAD` and `PUT`, evaluated in §13.2.2's order and **before** the `Range`, since a `304` and a `412` are answers about the whole representation. A `304` carries the validators and no content, not even a `Content-Length`. + +They are evaluated against [the derived ETag](#etags-are-derived) and the resource's `Last-Modified`, by the same code [the S3 gateway](/transports/s3) uses. `If-Match` compares strongly and `If-None-Match` weakly (§8.8.3.2), so a weak tag _from the client_ fails the first and passes the second. + +On a `PUT` to a URL with nothing at it, §13.1 decides per header: `If-Match` is `412` (there is no representation to match), `If-None-Match` passes — which is where `If-None-Match: *` means "create only if absent" — and the two date forms are ignored, because there is no modification date to compare with. + +A lock outranks them: a request that is both locked out and conditionally stale answers `423`, which is the one the client has to resolve first. + +`DELETE`, `COPY` and `MOVE` ignore all four. The header a WebDAV client reaches for on those is `If`, which _is_ enforced. + +## `createWebdavServer(driver, options?)` + +```ts +function createWebdavServer(driver: FsDriver, options?: WebdavServerOptions): WebdavServer; +``` + +Returns immediately; nothing is bound until `listen()`. It throws right away for a `host` [it will not bind](#who-may-connect), because a refusal that waits for `listen()` is a refusal that has already opened a socket. + +### `WebdavServerOptions` + +Extends [`WebdavSessionOptions`](#webdavsession), so everything the session takes is settable here too. + +| option | default | | +| ------------------ | ------------- | --------------------------------------------------------------------------- | +| `host` | `"127.0.0.1"` | address to bind — see [Who may connect](#who-may-connect) | +| `port` | `0` | an ephemeral port, which `WebdavServer.port` then reports; never 80 or 8080 | +| `credentials` | none | `{ username, password }` — present enables Basic auth and any bind | +| `realm` | `"mountx"` | the realm named in `WWW-Authenticate` | +| `drainTimeout` | `5000` | ms `close()` lets in-flight responses finish before dropping connections | +| `onTransportError` | none | `(error, peer)` — a socket error, or a reply that could not be written | + +### `WebdavServer` + +```ts +interface WebdavServer extends AsyncDisposable { + readonly session: WebdavSession; + readonly host: string; + readonly port: number; + readonly url: string; // e.g. "http://127.0.0.1:54321"; IPv6 bracketed + readonly connections: number; + listen(): Promise; // idempotent; resolves once bound + close(): Promise; // stop accepting, drain, drop — idempotent +} +``` + +### `WebdavBindError` / `isWebdavBindError()` / `isLoopbackHost()` + +```ts +class WebdavBindError extends Error { + readonly code: "ERR_WEBDAV_BIND"; + readonly host: string; +} +``` + +The one error type `createWebdavServer()` throws for an address, named so it can be caught rather than pattern-matched on a message. + +## `WebdavSession` + +```ts +new WebdavSession(driver: FsDriver, options?: WebdavSessionOptions) +``` + +One HTTP request in, one WebDAV reply out, with no socket anywhere — the same posture `S3Session`, `FuseSession` and `NfsSession` keep, and what makes the protocol testable with no listener and no client. + +```ts +session.driver; // Loopback — the driver, normalized, with gaps answering ENOSYS +session.locks; // DavLockTable — every write lock this share holds +session.stats; // { requests, replies, errors, methods: Map, assertions } +await session.handleRequest(head, body?); // → WebdavResponse; never rejects +``` + +`handleRequest`'s boundary is **streaming** in both directions: the request body and the reply body may each be an `AsyncIterable`, because a multi-gigabyte `PUT` or `GET` is not something to buffer. + +### `WebdavSessionOptions` + +| option | default | | +| ---------------- | ----------------------- | ------------------------------------------------------------------ | +| `credentials` | none | `{ username, password }`; present authenticates every request | +| `realm` | `"mountx"` | the realm named in `WWW-Authenticate` | +| `maxBodyBytes` | unlimited | cap on a `PUT` body; over it is `413` | +| `maxXmlBytes` | 256 KiB | cap on a `PROPFIND`/`PROPPATCH`/`LOCK` document | +| `readChunkBytes` | 128 KiB | bytes per positional read while streaming a `GET` | +| `now` | `Date.now` | the clock a lock's lease is measured against | +| `locks` | see [locking](#locking) | `{ defaultTimeoutSeconds, maxTimeoutSeconds, maxLocks, newToken }` | +| `debug` | on outside production | run the reply-exactly-once assertions | +| `onError` | none | called for every request that ends in an error reply | +| `onAssertion` | collect | called when a dev-mode assertion fails | + +## The layers below + +| module | | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `constants.ts` | the errno → HTTP status table (total over every `ErrnoCode`), the protocol's literals, and the `propstat` phrases | +| `protocol.ts` | pure parsing and document building — target ↔ `href`, `Depth`/`Overwrite`/`Destination`/`Timeout`/`Lock-Token`/`If`, and every document | +| `locks.ts` | `DavLockTable` — the write locks: pure, synchronous and clockless, with `now` an argument rather than a call | +| `session.ts` | `WebdavSession` — the method semantics, over one driver | +| `server.ts` | the socket, and the only file that imports `node:http` | + +::note +Documents go out with `DAV:` as the **default** namespace — `` with unprefixed children — rather than with RFC 4918's `D:` prefix. To a namespace-aware parser they are the same document (§14 binds names to the namespace, never to a prefix). Coming in, a property named in some _other_ namespace (Finder's and Office's `Win32*` properties) is matched on its local name and echoed back in `DAV:`, inside the `404` propstat where the client is looking only at the status. +:: + +## Not available + +- **Dead properties.** `PROPPATCH` stores `getlastmodified` and refuses every other property rather than accepting one it cannot keep. +- **Conditional requests on `DELETE`, `COPY` and `MOVE`.** The four RFC 9110 headers are honoured on `GET`, `HEAD` and `PUT`; on the rest, `If` is the header to use. +- **Lock-null resources.** RFC 2518's; `LOCK` on an unmapped URL creates [RFC 4918's locked empty resource](#lock-on-a-url-with-nothing-at-it) instead. +- **A property in a namespace other than `DAV:`.** It is matched on its local name and echoed back in `DAV:`, inside the `404` propstat — bounded to properties this server does not have. +- **Symlinks, hardlinks, permissions and access time.** WebDAV has no way to name any of them, so there is nothing to carry even where the driver underneath has one — see [above](#symbolic-links-are-followed-for-bytes-never-walked) for what the recursive methods do when they meet a link anyway. +- **`Content-Type` from the resource.** Not stored, not sniffed: every non-collection answers `application/octet-stream`, and a collection `httpd/unix-directory`. +- **Windows**, not because anything here is platform-specific — it is `node:http` over a portable driver — but because it has not been run there. + +## Next + +- [S3](/transports/s3) — the other transport that serves rather than mounts. +- [FUSE](/transports/fuse), [9P](/transports/9p) and [NFS](/transports/nfs) — the three that produce a mountpoint. diff --git a/docs/3.reference/0.index.md b/docs/3.reference/0.index.md index 48b86aa..b3865dd 100644 --- a/docs/3.reference/0.index.md +++ b/docs/3.reference/0.index.md @@ -18,8 +18,9 @@ title: Entry Points | [`mountx/9p`](/transports/9p) | the 9P2000.L transport, and `createP9Server()` | | [`mountx/nfs`](/transports/nfs) | the NFS transport (v3 default, v4.1 opt-in), and `createNfsServer()` | | [`mountx/s3`](/transports/s3) | the S3 gateway, and `createS3Server()` — not a mount transport | +| [`mountx/webdav`](/transports/webdav) | the WebDAV server, and `createWebdavServer()` — not a mount either | -The three `mountx/drivers/*` subpaths are documented in the guide, beside the interface they implement: [built-in drivers](/guide/drivers/built-in). `mountx/auto` and the four transport subpaths are documented in [Transports](/transports), beside the protocols they speak. +The three `mountx/drivers/*` subpaths are documented in the guide, beside the interface they implement: [built-in drivers](/guide/drivers/built-in). `mountx/auto` and the five transport subpaths are documented in [Transports](/transports), beside the protocols they speak. Plus the [`mountx` CLI](/guide/cli), which the package installs as a binary — a demo and a test bench, documented in the guide. diff --git a/docs/3.reference/2.capabilities.md b/docs/3.reference/2.capabilities.md index 91793ce..14a437a 100644 --- a/docs/3.reference/2.capabilities.md +++ b/docs/3.reference/2.capabilities.md @@ -64,7 +64,7 @@ interface MountxExtensions { Transports probe for each member and degrade without it, and both are consumed today: -- **`mknod`** — by all three mount transports (four sessions, counting NFSv3 and NFSv4.1 apart), and implemented by the [memory driver](/guide/drivers/built-in#memory). Without it, `MKNOD`, `Tmknod` and NFSv4.1's `CREATE` answer `ENOSYS`/`NOTSUPP` for anything but a regular file. The [S3 gateway](/transports/s3) does not use it and cannot: object storage has no way to name a FIFO or a device node. +- **`mknod`** — by all three mount transports (four sessions, counting NFSv3 and NFSv4.1 apart), and implemented by the [memory driver](/guide/drivers/built-in#memory). Without it, `MKNOD`, `Tmknod` and NFSv4.1's `CREATE` answer `ENOSYS`/`NOTSUPP` for anything but a regular file. Neither serving transport uses it, and neither can: object storage has no way to name a FIFO or a device node, and [WebDAV](/transports/webdav) has no way to name one either. - **`utimens`** — by FUSE and 9P, whose wires carry the nanoseconds `fs.utimes` would round away. NFS's `SETATTR` carries them too, but its sessions have not adopted the extension. Deliberately just the path-shaped gaps: locks, `fallocate`, `lseek` and cache-invalidation notifies are per-open-file or session-scoped, so they get designed with the session layer rather than guessed at here. The four `xattr` calls lived here as types with no consumer and were removed; re-adding them is type-only, and belongs with the session work that would answer the opcodes. diff --git a/package.json b/package.json index bd1ccfb..298647d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,10 @@ "types": "./dist/s3/index.d.mts", "default": "./dist/s3/index.mjs" }, + "./webdav": { + "types": "./dist/webdav/index.d.mts", + "default": "./dist/webdav/index.mjs" + }, "./drivers/memory": { "types": "./dist/drivers/memory.d.mts", "default": "./dist/drivers/memory.mjs" @@ -77,9 +81,10 @@ "test:mount": "sh test/root.sh test/fuse/mount.test.ts", "test:nfs:mount": "sh test/root.sh test/nfs/mount.test.ts", "test:9p:mount": "sh test/root.sh test/9p/mount.test.ts", + "test:webdav:mount": "sh test/root.sh test/webdav/mount.test.ts", "test:pjdfstest": "sh test/pjdfstest/run.sh", "test:rootless": "sh test/rootless.sh test/fuse/mount-rootless.test.ts test/auto-mount.test.ts test/nfs/mount.test.ts", - "test:root": "sh test/root.sh test/fuse/mount.test.ts test/fuse/differential.test.ts test/fuse/conformance-mount.test.ts test/nfs/mount.test.ts test/9p/mount.test.ts", + "test:root": "sh test/root.sh test/fuse/mount.test.ts test/fuse/differential.test.ts test/fuse/conformance-mount.test.ts test/nfs/mount.test.ts test/9p/mount.test.ts test/webdav/mount.test.ts", "typecheck": "tsc --noEmit --skipLibCheck" }, "devDependencies": { diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..93682c7 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,453 @@ +/** + * The HTTP the two HTTP transports share: `HTTP-date`, `Range`, `ETag`, the two + * entity-tag comparison functions, and the conditional-request rules built on + * them. + * + * All of it is **RFC 9110**, none of it is S3's or WebDAV's, and it lives here + * for the same reason `src/errors.ts` holds one errno table: a wire format + * transcribed twice is a wire format that will be transcribed differently twice + * (`AGENTS.md`, invariants 6 and 7). `src/s3/protocol.ts` re-exports every + * symbol below under its own name, so `mountx/s3`'s surface is unchanged and + * the S3 gateway still reads as though it owned them. + * + * Pure and clockless: a timestamp is always an argument, never `Date.now()`. + */ + +// --------------------------------------------------------------------------- +// dates +// --------------------------------------------------------------------------- + +const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const MONTH_NAMES = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +/** The widest millisecond timestamp `Date` represents (ECMA-262, `Date` range). */ +export const MAX_TIMESTAMP_MS = 8.64e15; + +function two(value: number): string { + return String(value).padStart(2, "0"); +} + +/** + * An `IMF-fixdate`, the one format a sender may use (RFC 9110 §5.6.7): + * `Sun, 06 Nov 1994 08:49:37 GMT`. + * + * Built from the UTC fields rather than `toUTCString()` so the output is this + * module's own, and so a non-finite timestamp is a caller error here rather + * than the string `"Invalid Date"` on the wire. + */ +export function formatHttpDate(timestamp: number): string { + const date = new Date(timestamp); + return ( + `${DAY_NAMES[date.getUTCDay()]}, ${two(date.getUTCDate())} ` + + `${MONTH_NAMES[date.getUTCMonth()]} ${date.getUTCFullYear()} ` + + `${two(date.getUTCHours())}:${two(date.getUTCMinutes())}:${two(date.getUTCSeconds())} GMT` + ); +} + +/** + * The ISO 8601 form S3 puts in XML documents (`LastModified`, `CreationDate`): + * `1994-11-06T08:49:37.000Z`, always with milliseconds and always UTC. + */ +export function formatIsoDate(timestamp: number): string { + return new Date(timestamp).toISOString(); +} + +const IMF_FIXDATE = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/; + +const RFC850_DATE = + /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Za-z]{3})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/; + +const ASCTIME_DATE = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Za-z]{3}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; + +function utcOf( + year: number, + monthName: string, + day: number, + hour: number, + minute: number, + second: number, +): number | undefined { + const month = MONTH_NAMES.indexOf(monthName); + /* `year < 100` is refused rather than passed to `Date.UTC`, which maps 0..99 + onto 1900..1999 — so `0099` would silently become 1999. A four-digit year + under 100 is not a date any client meant. */ + if ( + month === -1 || + year < 100 || + day < 1 || + day > 31 || + hour > 23 || + minute > 59 || + second > 60 + ) { + return undefined; + } + const timestamp = Date.UTC(year, month, day, hour, minute, Math.min(second, 59)); + /* Date.UTC rolls a day past the month's end forward; a date that does not + survive the round trip was never a real one. */ + if (!Number.isFinite(timestamp) || new Date(timestamp).getUTCDate() !== day) { + return undefined; + } + return timestamp; +} + +/** + * Parse an `HTTP-date` into a millisecond epoch, or `undefined` for anything + * that is not one. + * + * All three formats RFC 9110 §5.6.7 requires a recipient to accept: the + * preferred `IMF-fixdate`, the obsolete RFC 850 form, and `asctime()`. A + * two-digit RFC 850 year uses the fixed `69`/`70` split rather than the + * "50 years in the future" rule, because that rule needs a clock and this + * module does not have one — the difference only shows up for dates after 2069, + * in a format no client has sent this century. + * + * A leap second (`:60`) is accepted and read as `:59`, which is what RFC 9110 + * recommends. Never throws: an unparseable date is `undefined`, and every + * conditional header treats that as absent (RFC 9110 §13.1.3/§13.1.4). + */ +export function parseHttpDate(value: string): number | undefined { + const fixdate = IMF_FIXDATE.exec(value); + if (fixdate !== null) { + return utcOf( + Number(fixdate[3]), + fixdate[2] as string, + Number(fixdate[1]), + Number(fixdate[4]), + Number(fixdate[5]), + Number(fixdate[6]), + ); + } + const rfc850 = RFC850_DATE.exec(value); + if (rfc850 !== null) { + const short = Number(rfc850[3]); + return utcOf( + short >= 70 ? 1900 + short : 2000 + short, + rfc850[2] as string, + Number(rfc850[1]), + Number(rfc850[4]), + Number(rfc850[5]), + Number(rfc850[6]), + ); + } + const asctime = ASCTIME_DATE.exec(value); + if (asctime !== null) { + return utcOf( + Number(asctime[6]), + asctime[1] as string, + Number(asctime[2]), + Number(asctime[3]), + Number(asctime[4]), + Number(asctime[5]), + ); + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Range +// --------------------------------------------------------------------------- + +/** + * What a `Range` header asked for, against a known resource size. + * + * - `full` — no range, or one this server must ignore. RFC 9110 §14.2 is + * explicit that an unsatisfiable *syntax* is ignored rather than refused, and + * a multi-range request is ignored here too: neither transport over this + * answers a `multipart/byteranges` document, and S3 itself does not either + * ("Amazon S3 doesn't support retrieving multiple ranges of data per GET + * request") — what goes back is `200` with the whole resource. + * - `range` — a satisfiable single range, already clamped to the resource. + * - `unsatisfiable` — the 416 case (`Content-Range: bytes * /n`). + */ +export type RangeSpec = + | { kind: "full" } + | { kind: "range"; start: number; end: number; length: number } + | { kind: "unsatisfiable" }; + +/* Fresh objects rather than shared constants: a route object handed to a + caller should never be a value another request can see mutated. */ +function full(): RangeSpec { + return { kind: "full" }; +} + +function unsatisfiable(): RangeSpec { + return { kind: "unsatisfiable" }; +} + +/** + * A `first-byte-pos`/`last-byte-pos`/`suffix-length`: digits only, and read as + * a plain number even past `Number.MAX_SAFE_INTEGER` — a position that large is + * only ever compared against a size, and `1e20 >= size` is true whether or not + * the digits were exact. + */ +function rangeNumber(value: string): number | undefined { + return /^\d+$/.test(value) ? Number(value) : undefined; +} + +/** + * Parse a `Range` header against a resource of `size` bytes (RFC 9110 §14.1). + * + * The three forms: `bytes=a-b`, `bytes=a-` and `bytes=-n`. A range unit other + * than `bytes`, more than one range, or anything that does not parse is + * **ignored** — the whole resource, status 200 — which is what RFC 9110 + * requires of an unparseable header (see {@link RangeSpec} on the multi-range + * case). + * + * Unsatisfiable, per §14.1.1 and §14.4: `first-byte-pos` at or past the end of + * the resource, or a `suffix-length` of zero. An empty resource therefore refuses + * every byte range, including `bytes=0-`, since there is no byte 0 to serve. + */ +export function parseRange(value: string | undefined, size: number): RangeSpec { + if (value === undefined || value === "") { + return full(); + } + const equals = value.indexOf("="); + if (equals === -1 || value.slice(0, equals).trim().toLowerCase() !== "bytes") { + return full(); + } + const spec = value.slice(equals + 1).trim(); + if (spec.includes(",")) { + return full(); + } + const dash = spec.indexOf("-"); + if (dash === -1) { + return full(); + } + const firstText = spec.slice(0, dash).trim(); + const lastText = spec.slice(dash + 1).trim(); + if (firstText === "") { + /* `bytes=-n`: the last n bytes. */ + const suffix = rangeNumber(lastText); + if (suffix === undefined) { + return full(); + } + if (suffix === 0 || size === 0) { + return unsatisfiable(); + } + const start = Math.max(0, size - suffix); + return { kind: "range", start, end: size - 1, length: size - start }; + } + const first = rangeNumber(firstText); + if (first === undefined) { + return full(); + } + if (lastText === "") { + /* `bytes=a-`: to the end. */ + if (first >= size) { + return unsatisfiable(); + } + return { kind: "range", start: first, end: size - 1, length: size - first }; + } + const last = rangeNumber(lastText); + if (last === undefined || last < first) { + /* An invalid range spec makes the whole header invalid (§14.1.1). */ + return full(); + } + if (first >= size) { + return unsatisfiable(); + } + const end = Math.min(last, size - 1); + return { kind: "range", start: first, end, length: end - first + 1 }; +} + +// --------------------------------------------------------------------------- +// entity tags +// --------------------------------------------------------------------------- + +/** One entity tag, with the weakness marker kept: `W/` is part of the tag. */ +export interface ETag { + /** The opaque value, without quotes and without the `W/` prefix. */ + value: string; + /** Was it sent as `W/"..."`? */ + weak: boolean; +} + +/** An entity tag list: `*`, or the tags as sent. */ +export type ETagList = { any: true } | { any: false; tags: ETag[] }; + +/** Take a tag apart: `W/"abc"` is `{ value: "abc", weak: true }`. */ +export function parseETag(value: string): ETag { + const weak = value.startsWith("W/"); + const withoutWeak = weak ? value.slice(2) : value; + const unquoted = + withoutWeak.startsWith(`"`) && withoutWeak.endsWith(`"`) && withoutWeak.length >= 2 + ? withoutWeak.slice(1, -1) + : withoutWeak; + return { value: unquoted, weak }; +} + +/** + * Parse an `If-Match`/`If-None-Match` value (RFC 9110 §13.1.1/§13.1.2). + * + * The weakness marker is **kept**, because the two headers do not compare tags + * the same way: `If-Match` uses the strong comparison function and `If-None- + * Match` the weak one (§8.8.3.2). The client controls its side of that + * comparison, so `If-Match: W/"x"` never matches anything — including a + * representation whose strong ETag is `x` — while `If-None-Match: W/"x"` does. + */ +export function parseETagList(value: string): ETagList { + if (value.trim() === "*") { + return { any: true }; + } + const tags: ETag[] = []; + for (const part of value.split(",")) { + const trimmed = part.trim(); + if (trimmed !== "") { + tags.push(parseETag(trimmed)); + } + } + return { any: false, tags }; +} + +/** + * The **strong** comparison function (RFC 9110 §8.8.3.2): the values match and + * *neither* tag is weak. `*` matches any existing representation. + */ +export function etagMatchesStrongly(list: ETagList, etag: string): boolean { + if (list.any) { + return true; + } + const target = parseETag(etag); + return !target.weak && list.tags.some((tag) => !tag.weak && tag.value === target.value); +} + +/** + * The **weak** comparison function: the values match, whatever either side's + * weakness marker says. + */ +export function etagMatchesWeakly(list: ETagList, etag: string): boolean { + if (list.any) { + return true; + } + const target = parseETag(etag); + return list.tags.some((tag) => tag.value === target.value); +} + +/** Wrap an ETag in quotes if it is not already quoted. */ +export function formatETag(etag: string): string { + return etag.startsWith(`"`) && etag.endsWith(`"`) && etag.length >= 2 ? etag : `"${etag}"`; +} + +// --------------------------------------------------------------------------- +// conditional requests +// --------------------------------------------------------------------------- + +/** + * The four conditional header fields, by their lowercase names. + * + * A plain record rather than either transport's own header shape: WebDAV hands + * over exactly this (`node:http` lowercases and combines), and the S3 gateway — + * which must keep its headers as a list, because SigV4 signs them as they were + * sent — builds one at the call site, joining repeated `If-Match`/`If-None- + * Match` lines the way RFC 9110 §5.3 permits. Only these four names are read. + */ +export type ConditionalHeaders = Readonly>; + +/** What the conditional headers are evaluated against. */ +export interface ConditionalTarget { + /** The representation's ETag, quoted or not — both compare the same. */ + etag: string; + /** Its modification time, in milliseconds. */ + mtimeMs: number; +} + +/** The outcome: serve it, answer `304`, or answer `412`. */ +export interface ConditionalResult { + status: 200 | 304 | 412; +} + +/** + * Evaluate the four conditional headers in RFC 9110 §13.2.2's order: + * + * 1. `If-Match` — no match is `412`. + * 2. `If-Unmodified-Since`, **only when `If-Match` is absent** — modified since + * is `412`. + * 3. `If-None-Match` — a match is `304` for `GET`/`HEAD` and `412` for every + * other method. + * 4. `If-Modified-Since`, **only when `If-None-Match` is absent and the method + * is `GET` or `HEAD`** — not modified is `304`. + * + * `If-Match` compares strongly and `If-None-Match` weakly (§8.8.3.2), which is + * a difference a client can see even though every ETag either transport + * produces is strong: a weak tag *from the client* fails `If-Match` and passes + * `If-None-Match`. + * + * A date that does not parse is ignored, as §13.1.3 and §13.1.4 require ("a + * recipient MUST ignore the header field if the value is not a valid + * HTTP-date"). Comparison is at one-second resolution, because that is all an + * `HTTP-date` carries: an object modified 300 ms after the date in the header + * counts as *not* modified. + */ +export function evaluateConditionals( + target: ConditionalTarget, + headers: ConditionalHeaders, + method: string, +): ConditionalResult { + const safe = method === "GET" || method === "HEAD"; + const modifiedSeconds = Math.floor(target.mtimeMs / 1000); + + const ifMatch = headers["if-match"]; + if (ifMatch !== undefined) { + if (!etagMatchesStrongly(parseETagList(ifMatch), target.etag)) { + return { status: 412 }; + } + } else { + const ifUnmodifiedSince = headers["if-unmodified-since"]; + if (ifUnmodifiedSince !== undefined) { + const at = parseHttpDate(ifUnmodifiedSince); + if (at !== undefined && modifiedSeconds > Math.floor(at / 1000)) { + return { status: 412 }; + } + } + } + + const ifNoneMatch = headers["if-none-match"]; + if (ifNoneMatch !== undefined) { + if (etagMatchesWeakly(parseETagList(ifNoneMatch), target.etag)) { + return { status: safe ? 304 : 412 }; + } + return { status: 200 }; + } + + if (safe) { + const ifModifiedSince = headers["if-modified-since"]; + if (ifModifiedSince !== undefined) { + const at = parseHttpDate(ifModifiedSince); + if (at !== undefined && modifiedSeconds <= Math.floor(at / 1000)) { + return { status: 304 }; + } + } + } + return { status: 200 }; +} + +// --------------------------------------------------------------------------- +// Content-Range +// --------------------------------------------------------------------------- + +/** `Content-Range: bytes 0-99/1234` (RFC 9110 §14.4). */ +export function formatContentRange(start: number, end: number, total: number): string { + return `bytes ${start}-${end}/${total}`; +} + +/** The `Content-Range` of a 416 reply: `bytes * /n`, the "unsatisfied-range" form. */ +export function formatUnsatisfiedRange(total: number): string { + return `bytes */${total}`; +} diff --git a/src/s3/protocol.ts b/src/s3/protocol.ts index df847ab..05f48c3 100644 --- a/src/s3/protocol.ts +++ b/src/s3/protocol.ts @@ -48,6 +48,15 @@ import { MAX_PARTS, MULTIPART_PREFIX, } from "./constants.ts"; +import { + evaluateConditionals as evaluateHttpConditionals, + formatContentRange, + formatETag, + formatHttpDate, + MAX_TIMESTAMP_MS, + type ConditionalResult, + type ConditionalTarget, +} from "../http.ts"; import { normalizePath } from "../path.ts"; import type { HeaderEntry, QueryEntry, SigV4RefusalReason } from "./sigv4.ts"; import type { XmlRefusal } from "./xml.ts"; @@ -489,151 +498,35 @@ export function s3ErrorResponse(error: S3ErrorSpec, extra: S3ErrorExtra = {}): S } // --------------------------------------------------------------------------- -// dates +// dates, Range and ETag: RFC 9110, and shared // --------------------------------------------------------------------------- -const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - -const MONTH_NAMES = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -]; - -/** The widest millisecond timestamp `Date` represents (ECMA-262, `Date` range). */ -export const MAX_TIMESTAMP_MS = 8.64e15; - -function two(value: number): string { - return String(value).padStart(2, "0"); -} - -/** - * An `IMF-fixdate`, the one format a sender may use (RFC 9110 §5.6.7): - * `Sun, 06 Nov 1994 08:49:37 GMT`. - * - * Built from the UTC fields rather than `toUTCString()` so the output is this - * module's own, and so a non-finite timestamp is a caller error here rather - * than the string `"Invalid Date"` on the wire. +/* + * `HTTP-date`, the `Range` grammar, the `ETag` quoting, the entity-tag + * comparison functions and the conditional-request rules are RFC 9110 rather + * than S3, and `mountx/webdav` answers the same ones — its `If` header + * (RFC 4918 §10.4) matches entity tags with the very same list parser. They + * live in `src/http.ts` and are re-exported here under the names they have + * always had, so this module's surface — and `mountx/s3`'s — is unchanged. + * `evaluateConditionals` is the one that is wrapped rather than re-exported: it + * takes this transport's header list and hands the shared rule a lookup. */ -export function formatHttpDate(timestamp: number): string { - const date = new Date(timestamp); - return ( - `${DAY_NAMES[date.getUTCDay()]}, ${two(date.getUTCDate())} ` + - `${MONTH_NAMES[date.getUTCMonth()]} ${date.getUTCFullYear()} ` + - `${two(date.getUTCHours())}:${two(date.getUTCMinutes())}:${two(date.getUTCSeconds())} GMT` - ); -} - -/** - * The ISO 8601 form S3 puts in XML documents (`LastModified`, `CreationDate`): - * `1994-11-06T08:49:37.000Z`, always with milliseconds and always UTC. - */ -export function formatIsoDate(timestamp: number): string { - return new Date(timestamp).toISOString(); -} - -const IMF_FIXDATE = - /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/; - -const RFC850_DATE = - /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Za-z]{3})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/; - -const ASCTIME_DATE = - /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Za-z]{3}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; - -function utcOf( - year: number, - monthName: string, - day: number, - hour: number, - minute: number, - second: number, -): number | undefined { - const month = MONTH_NAMES.indexOf(monthName); - /* `year < 100` is refused rather than passed to `Date.UTC`, which maps 0..99 - onto 1900..1999 — so `0099` would silently become 1999. A four-digit year - under 100 is not a date any client meant. */ - if ( - month === -1 || - year < 100 || - day < 1 || - day > 31 || - hour > 23 || - minute > 59 || - second > 60 - ) { - return undefined; - } - const timestamp = Date.UTC(year, month, day, hour, minute, Math.min(second, 59)); - /* Date.UTC rolls a day past the month's end forward; a date that does not - survive the round trip was never a real one. */ - if (!Number.isFinite(timestamp) || new Date(timestamp).getUTCDate() !== day) { - return undefined; - } - return timestamp; -} - -/** - * Parse an `HTTP-date` into a millisecond epoch, or `undefined` for anything - * that is not one. - * - * All three formats RFC 9110 §5.6.7 requires a recipient to accept: the - * preferred `IMF-fixdate`, the obsolete RFC 850 form, and `asctime()`. A - * two-digit RFC 850 year uses the fixed `69`/`70` split rather than the - * "50 years in the future" rule, because that rule needs a clock and this - * module does not have one — the difference only shows up for dates after 2069, - * in a format no client has sent this century. - * - * A leap second (`:60`) is accepted and read as `:59`, which is what RFC 9110 - * recommends. Never throws: an unparseable date is `undefined`, and every - * conditional header treats that as absent (RFC 9110 §13.1.3/§13.1.4). - */ -export function parseHttpDate(value: string): number | undefined { - const fixdate = IMF_FIXDATE.exec(value); - if (fixdate !== null) { - return utcOf( - Number(fixdate[3]), - fixdate[2] as string, - Number(fixdate[1]), - Number(fixdate[4]), - Number(fixdate[5]), - Number(fixdate[6]), - ); - } - const rfc850 = RFC850_DATE.exec(value); - if (rfc850 !== null) { - const short = Number(rfc850[3]); - return utcOf( - short >= 70 ? 1900 + short : 2000 + short, - rfc850[2] as string, - Number(rfc850[1]), - Number(rfc850[4]), - Number(rfc850[5]), - Number(rfc850[6]), - ); - } - const asctime = ASCTIME_DATE.exec(value); - if (asctime !== null) { - return utcOf( - Number(asctime[6]), - asctime[1] as string, - Number(asctime[2]), - Number(asctime[3]), - Number(asctime[4]), - Number(asctime[5]), - ); - } - return undefined; -} +export { + formatContentRange, + formatETag, + formatHttpDate, + formatIsoDate, + formatUnsatisfiedRange, + MAX_TIMESTAMP_MS, + parseETagList, + parseHttpDate, + parseRange, + type ConditionalResult, + type ConditionalTarget, + type ETag, + type ETagList, + type RangeSpec, +} from "../http.ts"; // --------------------------------------------------------------------------- // the request target @@ -1635,261 +1528,34 @@ export function routeRequest( } } -// --------------------------------------------------------------------------- -// Range -// --------------------------------------------------------------------------- - -/** - * What a `Range` header asked for, against a known object size. - * - * - `full` — no range, or one this server must ignore. RFC 9110 §14.2 is - * explicit that an unsatisfiable *syntax* is ignored rather than refused, and - * S3 additionally ignores a multi-range request: "Amazon S3 doesn't support - * retrieving multiple ranges of data per GET request", and what it sends back - * is `200` with the whole object, not a `multipart/byteranges` document. - * - `range` — a satisfiable single range, already clamped to the object. - * - `unsatisfiable` — the 416 case (`InvalidRange`, `Content-Range: bytes * /n`). - */ -export type RangeSpec = - | { kind: "full" } - | { kind: "range"; start: number; end: number; length: number } - | { kind: "unsatisfiable" }; - -/* Fresh objects rather than shared constants: a route object handed to a - caller should never be a value another request can see mutated. */ -function full(): RangeSpec { - return { kind: "full" }; -} - -function unsatisfiable(): RangeSpec { - return { kind: "unsatisfiable" }; -} - -/** - * A `first-byte-pos`/`last-byte-pos`/`suffix-length`: digits only, and read as - * a plain number even past `Number.MAX_SAFE_INTEGER` — a position that large is - * only ever compared against a size, and `1e20 >= size` is true whether or not - * the digits were exact. - */ -function rangeNumber(value: string): number | undefined { - return /^\d+$/.test(value) ? Number(value) : undefined; -} - -/** - * Parse a `Range` header against an object of `size` bytes (RFC 9110 §14.1). - * - * The three forms: `bytes=a-b`, `bytes=a-` and `bytes=-n`. A range unit other - * than `bytes`, more than one range, or anything that does not parse is - * **ignored** — the whole object, status 200 — which is both what RFC 9110 - * requires of an unparseable header and what S3 does with a multi-range - * request. - * - * Unsatisfiable, per §14.1.1 and §14.4: `first-byte-pos` at or past the end of - * the object, or a `suffix-length` of zero. An empty object therefore refuses - * every byte range, including `bytes=0-`, since there is no byte 0 to serve. - */ -export function parseRange(value: string | undefined, size: number): RangeSpec { - if (value === undefined || value === "") { - return full(); - } - const equals = value.indexOf("="); - if (equals === -1 || value.slice(0, equals).trim().toLowerCase() !== "bytes") { - return full(); - } - const spec = value.slice(equals + 1).trim(); - if (spec.includes(",")) { - return full(); - } - const dash = spec.indexOf("-"); - if (dash === -1) { - return full(); - } - const firstText = spec.slice(0, dash).trim(); - const lastText = spec.slice(dash + 1).trim(); - if (firstText === "") { - /* `bytes=-n`: the last n bytes. */ - const suffix = rangeNumber(lastText); - if (suffix === undefined) { - return full(); - } - if (suffix === 0 || size === 0) { - return unsatisfiable(); - } - const start = Math.max(0, size - suffix); - return { kind: "range", start, end: size - 1, length: size - start }; - } - const first = rangeNumber(firstText); - if (first === undefined) { - return full(); - } - if (lastText === "") { - /* `bytes=a-`: to the end. */ - if (first >= size) { - return unsatisfiable(); - } - return { kind: "range", start: first, end: size - 1, length: size - first }; - } - const last = rangeNumber(lastText); - if (last === undefined || last < first) { - /* An invalid range spec makes the whole header invalid (§14.1.1). */ - return full(); - } - if (first >= size) { - return unsatisfiable(); - } - const end = Math.min(last, size - 1); - return { kind: "range", start: first, end, length: end - first + 1 }; -} - // --------------------------------------------------------------------------- // conditional requests // --------------------------------------------------------------------------- -/** One entity tag, with the weakness marker kept: `W/` is part of the tag. */ -export interface ETag { - /** The opaque value, without quotes and without the `W/` prefix. */ - value: string; - /** Was it sent as `W/"..."`? */ - weak: boolean; -} - -/** An entity tag list: `*`, or the tags as sent. */ -export type ETagList = { any: true } | { any: false; tags: ETag[] }; - -/** Take a tag apart: `W/"abc"` is `{ value: "abc", weak: true }`. */ -function parseETag(value: string): ETag { - const weak = value.startsWith("W/"); - const withoutWeak = weak ? value.slice(2) : value; - const unquoted = - withoutWeak.startsWith(`"`) && withoutWeak.endsWith(`"`) && withoutWeak.length >= 2 - ? withoutWeak.slice(1, -1) - : withoutWeak; - return { value: unquoted, weak }; -} - /** - * Parse an `If-Match`/`If-None-Match` value (RFC 9110 §13.1.1/§13.1.2). + * Evaluate the four conditional headers in RFC 9110 §13.2.2's order. * - * The weakness marker is **kept**, because the two headers do not compare tags - * the same way: `If-Match` uses the strong comparison function and `If-None- - * Match` the weak one (§8.8.3.2). The client controls its side of that - * comparison, so `If-Match: W/"x"` never matches anything — including an object - * whose strong ETag is `x` — while `If-None-Match: W/"x"` does. - */ -export function parseETagList(value: string): ETagList { - if (value.trim() === "*") { - return { any: true }; - } - const tags: ETag[] = []; - for (const part of value.split(",")) { - const trimmed = part.trim(); - if (trimmed !== "") { - tags.push(parseETag(trimmed)); - } - } - return { any: false, tags }; -} - -/** - * The **strong** comparison function (RFC 9110 §8.8.3.2): the values match and - * *neither* tag is weak. `*` matches any existing representation. - */ -function etagMatchesStrongly(list: ETagList, etag: string): boolean { - if (list.any) { - return true; - } - const target = parseETag(etag); - return !target.weak && list.tags.some((tag) => !tag.weak && tag.value === target.value); -} - -/** - * The **weak** comparison function: the values match, whatever either side's - * weakness marker says. - */ -function etagMatchesWeakly(list: ETagList, etag: string): boolean { - if (list.any) { - return true; - } - const target = parseETag(etag); - return list.tags.some((tag) => tag.value === target.value); -} - -/** What the conditional headers are evaluated against. */ -export interface ConditionalTarget { - /** The object's ETag, quoted or not — both compare the same. */ - etag: string; - /** The object's modification time, in milliseconds. */ - mtimeMs: number; -} - -/** The outcome: serve it, answer `304`, or answer `412`. */ -export interface ConditionalResult { - status: 200 | 304 | 412; -} - -/** - * Evaluate the four conditional headers in RFC 9110 §13.2.2's order: - * - * 1. `If-Match` — no match is `412`. - * 2. `If-Unmodified-Since`, **only when `If-Match` is absent** — modified since - * is `412`. - * 3. `If-None-Match` — a match is `304` for `GET`/`HEAD` and `412` for every - * other method. - * 4. `If-Modified-Since`, **only when `If-None-Match` is absent and the method - * is `GET` or `HEAD`** — not modified is `304`. - * - * `If-Match` compares strongly and `If-None-Match` weakly (§8.8.3.2), which is - * a difference a client can see even though every ETag this gateway produces is - * strong: a weak tag *from the client* fails `If-Match` and passes - * `If-None-Match`. - * - * A date that does not parse is ignored, as §13.1.3 and §13.1.4 require ("a - * recipient MUST ignore the header field if the value is not a valid - * HTTP-date"). Comparison is at one-second resolution, because that is all an - * `HTTP-date` carries: an object modified 300 ms after the date in the header - * counts as *not* modified. + * The rules are HTTP's rather than S3's and live in `src/http.ts` beside the + * entity-tag comparison functions they use; this is the S3 spelling of the same + * call, taking the header list this transport carries (SigV4 signs headers as + * they were sent, so they stay a list of entries here) and joining the two + * list-based fields the way RFC 9110 §5.3 permits. */ export function evaluateConditionals( target: ConditionalTarget, headers: readonly HeaderEntry[], method: string, ): ConditionalResult { - const safe = method === "GET" || method === "HEAD"; - const modifiedSeconds = Math.floor(target.mtimeMs / 1000); - - const ifMatch = headerList(headers, "if-match"); - if (ifMatch !== undefined) { - if (!etagMatchesStrongly(parseETagList(ifMatch), target.etag)) { - return { status: 412 }; - } - } else { - const ifUnmodifiedSince = headerValue(headers, "if-unmodified-since"); - if (ifUnmodifiedSince !== undefined) { - const at = parseHttpDate(ifUnmodifiedSince); - if (at !== undefined && modifiedSeconds > Math.floor(at / 1000)) { - return { status: 412 }; - } - } - } - - const ifNoneMatch = headerList(headers, "if-none-match"); - if (ifNoneMatch !== undefined) { - if (etagMatchesWeakly(parseETagList(ifNoneMatch), target.etag)) { - return { status: safe ? 304 : 412 }; - } - return { status: 200 }; - } - - if (safe) { - const ifModifiedSince = headerValue(headers, "if-modified-since"); - if (ifModifiedSince !== undefined) { - const at = parseHttpDate(ifModifiedSince); - if (at !== undefined && modifiedSeconds <= Math.floor(at / 1000)) { - return { status: 304 }; - } - } - } - return { status: 200 }; + return evaluateHttpConditionals( + target, + { + "if-match": headerList(headers, "if-match"), + "if-none-match": headerList(headers, "if-none-match"), + "if-modified-since": headerValue(headers, "if-modified-since"), + "if-unmodified-since": headerValue(headers, "if-unmodified-since"), + }, + method, + ); } // --------------------------------------------------------------------------- @@ -1933,22 +1599,6 @@ export function formatMetaMtime(timestamp: number): string { const seconds = timestamp / 1000; return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(3); } - -/** Wrap an ETag in quotes if it is not already quoted. */ -export function formatETag(etag: string): string { - return etag.startsWith(`"`) && etag.endsWith(`"`) && etag.length >= 2 ? etag : `"${etag}"`; -} - -/** `Content-Range: bytes 0-99/1234` (RFC 9110 §14.4). */ -export function formatContentRange(start: number, end: number, total: number): string { - return `bytes ${start}-${end}/${total}`; -} - -/** The `Content-Range` of a 416 reply: `bytes * /n`, the "unsatisfied-range" form. */ -export function formatUnsatisfiedRange(total: number): string { - return `bytes */${total}`; -} - /** What an object reply's headers are built from. */ export interface ObjectHeadersInput { /** The derived ETag; quoted for you. */ diff --git a/src/webdav/constants.ts b/src/webdav/constants.ts new file mode 100644 index 0000000..c62d360 --- /dev/null +++ b/src/webdav/constants.ts @@ -0,0 +1,302 @@ +/** + * WebDAV's constants, transcribed from **RFC 4918** (and RFC 9110 for the + * status codes it reuses). + * + * Two tables and a handful of literals. The table that matters is + * {@link ERRNO_STATUS}: it is typed **total** over `ErrnoCode`, the same shape + * and for the same reason as `src/s3/constants.ts`'s `ERRNO_S3_ERRORS` — a new + * errno in `src/errors.ts` is a type error here rather than an `EIO` a client + * sees as `500` and nobody notices. + * + * Nothing here is guessed from what a client happens to accept: every status is + * the one the RFC names for that situation, and where the RFC leaves a choice + * (`MAY be 405`) the choice is written down at the entry. + */ + +import type { ErrnoCode } from "../errors.ts"; + +// --------------------------------------------------------------------------- +// the protocol's literals +// --------------------------------------------------------------------------- + +/** + * The one XML namespace WebDAV defines (RFC 4918 §14, §21). + * + * It is `DAV:`, with the colon and with no trailing slash — an unusual URI, and + * one that a client comparing namespace strings rather than parsing them will + * only match spelled exactly this way. + */ +export const DAV_NS = "DAV:"; + +/** + * The `DAV` response header this server sends: **classes 1, 2 and 3** + * (RFC 4918 §10.1, §18). + * + * Class 1 is "everything in RFC 4918 except locking". Class 2 is `LOCK` and + * `UNLOCK` — the write locks of §6 and §7, both scopes, both depths, with the + * `If` header (§10.4) enforcing them. Class 3 is "this server is RFC 4918 + * rather than RFC 2518", which is a statement about the *revision* and is + * independent of locking. + * + * Every one of those is answered rather than claimed: `supportedlock` lists the + * two lock entries §15.10 defines, `lockdiscovery` reports the locks that + * really are held, and a mutating request without the token it needs is `423`. + * That is the "capabilities are declared-or-inferred, never faked" rule + * (`AGENTS.md`, invariant 5) applied to a protocol header — which is why this + * said `1, 3` until the locks underneath it existed. + */ +export const DAV_COMPLIANCE = "1, 2, 3"; + +/** + * The header Microsoft's WebDAV redirector looks for before it will treat an + * origin as a DAV share rather than a web site. + * + * Not in RFC 4918 — it is Microsoft's, and it is answered because the cost is + * one header and the alternative is a client that never sends a second request. + */ +export const MS_AUTHOR_VIA = "DAV"; + +/** Every method this server implements, in `Allow`-header order. */ +export const WEBDAV_METHODS = [ + "OPTIONS", + "HEAD", + "GET", + "PUT", + "DELETE", + "MKCOL", + "COPY", + "MOVE", + "PROPFIND", + "PROPPATCH", + "LOCK", + "UNLOCK", +] as const; + +/** One of {@link WEBDAV_METHODS}. */ +export type WebdavMethod = (typeof WEBDAV_METHODS)[number]; + +/** The `Allow` header, built once. */ +export const ALLOW_HEADER = WEBDAV_METHODS.join(", "); + +/** + * The content type of every non-collection resource this server serves. + * + * Not stored and not sniffed, exactly as in the S3 gateway: a driver holds + * bytes, not media types, and guessing one from a file extension is a guess + * that shows up as a browser rendering a text file as a download or the other + * way round. A client that needs a type knows the name it asked for. + */ +export const RESOURCE_CONTENT_TYPE = "application/octet-stream"; + +/** + * The content type reported for a collection: `httpd/unix-directory`. + * + * A convention rather than a standard — Apache's `mod_dav` set it, and every + * WebDAV client since has recognised it — and the honest answer is that a + * collection has no body at all (`GET` of one is `405` here). It is answered + * because `getcontenttype` on a collection is a property clients ask for, and + * the alternative is a `404` propstat for something the server does know. + */ +export const COLLECTION_CONTENT_TYPE = "httpd/unix-directory"; + +/** The content type of every `multistatus` and `error` document. */ +export const XML_CONTENT_TYPE = 'application/xml; charset="utf-8"'; + +/** + * Largest request body this server will parse as XML (`PROPFIND`, + * `PROPPATCH`), in bytes. 256 KiB. + * + * Far below the XML parser's own 4 MiB budget, because both grammars here are a + * short list of property names: a megabyte of `` is not a request any + * client makes, and the body is buffered before it is parsed. + */ +export const MAX_XML_BYTES = 256 * 1024; + +/** Bytes per positional read when streaming a `GET`. 128 KiB, as in `mountx/s3`. */ +export const READ_CHUNK_BYTES = 128 * 1024; + +// --------------------------------------------------------------------------- +// locking +// --------------------------------------------------------------------------- + +/** + * The URI scheme every lock token this server mints is written in + * (RFC 4918 §6.5, RFC 4122 §3). + * + * §6.5 requires a token to be unique "across all resources for all time" and + * *encourages* `urn:uuid:` over the older `opaquelocktoken:` scheme of + * RFC 2518 — so that is what is here, and it is also the form every example in + * §9.10 and §10.4 uses. A client must not interpret it either way (§6.5). + */ +export const LOCK_TOKEN_PREFIX = "urn:uuid:"; + +/** + * The lease a lock gets when the client asks for nothing, in seconds. Ten + * minutes. + * + * §6.6 leaves the number entirely to the server ("the lifetime is suggested by + * the client ... but the server ultimately chooses the timeout value"), so what + * decides it is what a lock costs when its holder disappears: **this server has + * no administrative interface, and no principal but the one in + * `credentials`**, so a lock nobody unlocks is a resource nobody can write + * until it lapses. Ten minutes is long enough to edit a document through and + * short enough that a crashed client is not a locked share for the afternoon. + * A client that wants longer refreshes (§9.10.2), which is the mechanism the + * RFC provides for exactly this. + */ +export const DEFAULT_LOCK_TIMEOUT_SECONDS = 600; + +/** + * The longest lease this server grants, in seconds. One hour. + * + * The cap is what `Timeout: Infinite` becomes — §6.6 lets a server choose, and + * an unbounded lock here would be unbreakable for the reason above. §10.7 caps + * the *header's* value at 2^32-1; this is a policy well inside it, and the + * granted value always goes back in the reply's `timeout` element so a client + * never has to guess what it got. + */ +export const MAX_LOCK_TIMEOUT_SECONDS = 3600; + +/** + * Most locks one table holds at once. 4096. + * + * Locking is the one part of this protocol where a client leaves something + * behind on the server, so it is the one part with a bound. Expired locks are + * swept before the cap is consulted, so this is reached only by live locks — + * four thousand of them, which is far past any real client and far short of a + * memory problem. Past it a `LOCK` is `503`, which says "not now" rather than + * "never" and is the truthful shape of a full table. + */ +export const MAX_LOCKS = 4096; + +// --------------------------------------------------------------------------- +// status codes +// --------------------------------------------------------------------------- + +/** + * Reason phrases for every status this server sends. + * + * It needs them for more than the status line: a `propstat` carries a whole + * `Status-Line` as element text (RFC 4918 §14.28), so `HTTP/1.1 404 Not Found` + * is a *value this module produces* rather than something `node:http` writes. + * + * `207`, `423` and `507` are RFC 4918's own (§11.1, §11.3, §11.5); `508` is + * RFC 5842's; the rest are RFC 9110 §15. + */ +export const STATUS_TEXT: Record = { + 200: "OK", + 201: "Created", + 204: "No Content", + 206: "Partial Content", + 207: "Multi-Status", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 409: "Conflict", + 412: "Precondition Failed", + 413: "Content Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 423: "Locked", + 424: "Failed Dependency", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 507: "Insufficient Storage", + 508: "Loop Detected", +}; + +/** + * A `Status-Line` for a `propstat` or a `response`: `HTTP/1.1 200 OK`. + * + * A status with no phrase in {@link STATUS_TEXT} is still rendered — the + * grammar's `reason-phrase` may be empty — rather than throwing inside a reply + * that is already half built. + */ +export function statusLine(status: number): string { + const phrase = STATUS_TEXT[status]; + return phrase === undefined ? `HTTP/1.1 ${status}` : `HTTP/1.1 ${status} ${phrase}`; +} + +/** + * Every errno a driver can throw, as the status a WebDAV client is owed. + * + * Total over `ErrnoCode` by type, so `src/errors.ts` and this table cannot + * drift. The entries that are a judgement rather than a lookup: + * + * - **`EEXIST` → 409**, not 405. `MKCOL` on an existing collection is 405 + * (§9.3.1) and the session answers that case itself, before any driver call; + * an `EEXIST` that reaches here came from somewhere else, where "the state of + * the destination is wrong for this request" is the general answer. + * - **`ENOTDIR` → 409**, which is §9.7.1's own case: a `PUT` whose parent is + * not a collection is a `Conflict`, not a `Not Found`. + * - **`EXDEV` → 502.** §9.9.4 gives `502` to a `MOVE` whose destination is + * somewhere this server cannot write, and a cross-device rename is exactly + * that seen from the driver. + * - **`ELOOP` → 508.** RFC 5842 §7.2 defines `Loop Detected` for a request that + * walked into a cycle; a symlink loop is one. + * - **`ENOSPC` / `EDQUOT` / `EFBIG` → 507 / 507 / 413.** The first two are the + * store having no room (§11.5); `EFBIG` is *this* resource being too big, + * which is a fact about the request body. + * - **`ENOSYS` / `ENOTSUP` → 501.** A driver without `rename` really has not + * implemented `MOVE`, and 501 is the answer that says so without blaming the + * client. + * - **`ENXIO` → 403.** The memory driver answers it for a FIFO or a device + * node, which is a resource WebDAV can name and cannot transfer. + */ +export const ERRNO_STATUS: Record = { + EPERM: 403, + ENOENT: 404, + EINTR: 500, + EIO: 500, + ENXIO: 403, + EBADF: 500, + EAGAIN: 503, + ENOMEM: 503, + EACCES: 403, + EBUSY: 409, + EEXIST: 409, + EXDEV: 502, + ENODEV: 404, + ENOTDIR: 409, + EISDIR: 405, + EINVAL: 400, + ENFILE: 503, + EMFILE: 503, + EFBIG: 413, + ENOSPC: 507, + ESPIPE: 500, + EROFS: 403, + EMLINK: 403, + ERANGE: 500, + ENAMETOOLONG: 414, + ENOSYS: 501, + ENOTEMPTY: 409, + ELOOP: 508, + ENODATA: 404, + EPROTO: 500, + EOVERFLOW: 500, + ENOTSUP: 501, + ESTALE: 404, + EDQUOT: 507, +}; + +/** The status an unrecognized failure gets: `500`, never a guess. */ +export const UNKNOWN_STATUS = 500; + +/** + * The status for any error a driver throws, by its `code`. + * + * Anything without a `code` this table knows is `500` — the same posture as + * `errnoOf()`'s `EIO` default, and for the same reason: an unmapped failure is + * the server's, not the client's. + */ +export function statusOf(code: string | undefined): number { + return code !== undefined && code in ERRNO_STATUS + ? (ERRNO_STATUS[code as ErrnoCode] as number) + : UNKNOWN_STATUS; +} diff --git a/src/webdav/index.ts b/src/webdav/index.ts new file mode 100644 index 0000000..d2f648a --- /dev/null +++ b/src/webdav/index.ts @@ -0,0 +1,36 @@ +/** + * The WebDAV server: `mountx/webdav`. + * + * The second transport that is not a mount — it serves an `FsDriver` to a + * WebDAV client (`rclone`, `curl`, `cadaver`, a file manager, `davfs2`) over + * HTTP, one driver per share. Nothing here produces a mountpoint, which is why + * it is outside `mountx/auto` for the same reason `mountx/s3` is: `auto`'s + * contract is a mountpoint, and this never makes one. + * + * **RFC 4918 classes 1, 2 and 3** — every method the specification defines, + * `LOCK` and `UNLOCK` included, with the `DAV` header saying so because each + * class is answered rather than claimed. `src/webdav/session.ts`'s header sets + * out what is here and what is deliberately not. + * + * Layered the way the other transports are: + * + * - `constants.ts` — the errno → status table (total over `ErrnoCode`), the + * protocol's literals, and the reason phrases a `propstat` needs. + * - `protocol.ts` — pure request parsing and document building: the target as a + * driver path and back as an `href`, `Depth`/`Overwrite`/`Destination`/ + * `Timeout`/`Lock-Token`/`If`, the three request grammars, `multistatus`, + * `error` and the lock documents. + * - `locks.ts` — the write-lock table: pure, synchronous and clockless, with + * `now` an argument and never a call. + * - `session.ts` — a request in, a reply out, over one driver, with no socket. + * - `server.ts` — the socket, and the only file that imports `node:http`. + * + * Everything except the last runs with no listener and no privileges, which is + * what makes the protocol testable without a client. + */ + +export * from "./constants.ts"; +export * from "./locks.ts"; +export * from "./protocol.ts"; +export * from "./server.ts"; +export * from "./session.ts"; diff --git a/src/webdav/locks.ts b/src/webdav/locks.ts new file mode 100644 index 0000000..9f0a41b --- /dev/null +++ b/src/webdav/locks.ts @@ -0,0 +1,423 @@ +/** + * WebDAV write locks: the table behind class 2 (RFC 4918 §6, §7). + * + * **This is not `src/9p/locks.ts`**, and the difference is the whole design. A + * 9P record lock is a POSIX byte range owned by a `(client_id, proc_id)` pair + * the client puts on the wire; a WebDAV lock is a *whole resource* — or a whole + * subtree — owned by a token **this server minted** and handed back, and the + * only thing that proves ownership is a client repeating that token in an `If` + * header (§7.5). Nothing here overlaps, splits or coalesces: a lock covers a + * path, or a path and everything under it, and that is the entire geometry. + * + * **This is not `src/lock.ts` either.** `PathLock` serializes a session's own + * work against its renames within one process; nothing in this file blocks + * anything. What is here is state a *client* created and can come back for + * minutes later, over a different connection. + * + * ## Pure, synchronous and clockless + * + * No `await`, no `Date.now()`, no timer. Every method that cares about time + * takes `now` as a millisecond argument, exactly the way `src/http.ts` and + * `src/s3/sigv4.ts` do, so the whole lease lifecycle is testable without + * waiting for one — and so `WebdavSession` remains the single place a real + * clock enters (`options.now`). The token generator is an option for the same + * reason: {@link DavLockTable} with a counter for `newToken` produces the same + * documents on every run. + * + * Expiry is therefore **lazy**: there is no timer sweeping the table, and a + * lapsed lock stops existing the next time anything looks at the table with a + * `now` past its deadline. §6.6 asks for exactly that shape ("if the timeout + * expires, then the lock SHOULD be removed ... the server SHOULD act as if an + * UNLOCK method was executed"), and it is also what a client is told to expect: + * "clients MUST assume that locks can arbitrarily disappear at any time". + * + * ## Scope: what a lock covers + * + * §7.4 defines two, and both are here: + * + * - **Depth 0** — the lock root itself. On a collection that is the collection + * and its membership: creating, removing or renaming an internal member of it + * needs the token, while the members' own contents do not. + * - **Depth infinity** — the root and every resource under it, which §6.1 + * calls *indirectly* locked. Membership changes move resources in and out of + * that set as they happen; nothing is recorded per member, because the set is + * a prefix test ({@link DavLockTable.covering}) rather than a list. + * + * `Depth: 1` is not a lock scope RFC 4918 has — §9.10.3 says values other than + * `0` or `infinity` "MUST NOT be used" — so {@link LockDepth} has two members + * and the refusal happens in the session, at the header. + * + * ## Conflicts + * + * §9.10.5's compatibility table, in one sentence: two locks whose scopes + * overlap conflict unless **both** are shared. That covers the indirect cases + * §6.1 point 3 insists on ("whether either lock is direct or indirect"), and + * the one §7.4 spells out — a depth-infinity `LOCK` over a subtree that already + * holds a conflicting lock is `423`, which is why {@link DavLockTable.conflict} + * looks *down* the tree as well as up it. + * + * ## A lock never follows its resource + * + * The sharpest contrast with `src/9p/locks.ts`, which remaps its ranges across + * a rename. Here §7.6 is explicit: "a successful MOVE request on a write locked + * resource MUST NOT move the write lock with the resource", and §6.1 point 8 + * settles what happens instead — "if a request causes the lock-root of any lock + * to become an unmapped URL, then the lock MUST also be deleted by that + * request". So a `MOVE` or `DELETE` of a lock root **destroys** that lock — the + * session walks {@link DavLockTable.within} the path it unmapped, checks each + * root and {@link DavLockTable.remove}s the ones that really went — and the + * moved resource arrives at its destination unlocked, except for whatever + * depth-infinity lock already covers the destination, which picks it up for free + * because coverage is a prefix test. + * + * ## Why the lookups are linear + * + * Every query walks the map. A share holds tens of locks, not thousands, and + * {@link MAX_LOCKS} makes that a bound rather than an assumption — while the + * queries that matter (`covering`, `conflict`) are prefix tests over a path, + * which no index this small would beat. The map is keyed by token because that + * is the one lookup with an exact key: `UNLOCK` and every `If` header name a + * lock by its token and nothing else. + */ + +import { randomUUID } from "node:crypto"; +import { isPathInside } from "../path.ts"; +import type { XmlNode } from "../s3/xml.ts"; +import { + DEFAULT_LOCK_TIMEOUT_SECONDS, + LOCK_TOKEN_PREFIX, + MAX_LOCK_TIMEOUT_SECONDS, + MAX_LOCKS, +} from "./constants.ts"; + +// --------------------------------------------------------------------------- +// what a lock is +// --------------------------------------------------------------------------- + +/** + * The two lock scopes RFC 4918 §9.10.3 allows on the `Depth` header of a + * `LOCK`. `1` is not one of them. + */ +export type LockDepth = 0 | "infinity"; + +/** + * One active write lock. + * + * Every field is `readonly` and a refresh replaces the whole record rather than + * mutating one ({@link DavLockTable.refresh}), so a lock handed to a caller is + * a snapshot that cannot change under it while it renders a document from it. + */ +export interface DavLock { + /** The token, `urn:uuid:…` — globally unique, and the only proof of + * ownership there is (§6.5). */ + readonly token: string; + /** The lock root as a driver path: the resource the `LOCK` named (§14.12). */ + readonly path: string; + /** + * Was the lock root a collection when the lock was taken? + * + * Carried only so `lockroot`'s `href` can end in `/` for one (§5.2), and + * never re-read: a resource that changed kind under a live lock is a resource + * that was deleted and recreated, which took the lock with it (§6.1 point 8). + */ + readonly collection: boolean; + /** `0` or `infinity` — see the module docs on what each covers (§7.4). */ + readonly depth: LockDepth; + /** Exclusive (§14.6) rather than shared (§14.27). */ + readonly exclusive: boolean; + /** + * The client's `` element, preserved verbatim as §9.10.1 requires + * ("the server MUST preserve the information provided by the client in the + * 'owner' element"), or `undefined` when the request carried none — §14.11 + * makes it optional. + * + * Kept by reference and never read for meaning: the node comes from a body + * that was buffered, parsed into fresh objects and then dropped, so there is + * nothing here aliasing a transport buffer (`AGENTS.md`, invariant 12). + */ + readonly owner: XmlNode | undefined; + /** The lease granted, in seconds — what the reply's `timeout` element said + * when the lock was taken or last refreshed. */ + readonly timeoutSeconds: number; + /** When the lease lapses, in milliseconds since the epoch. */ + readonly expiresAt: number; +} + +/** What a `LOCK` asks for, once the session has read the headers and body. */ +export interface DavLockRequest { + path: string; + collection: boolean; + depth: LockDepth; + exclusive: boolean; + owner?: XmlNode | undefined; + /** + * The `Timeout` header's request, in seconds, or `"infinite"` for + * `Timeout: Infinite` — both only ever *suggestions* (§6.6). Absent means the + * client asked for nothing. + */ + timeoutSeconds?: number | "infinite" | undefined; +} + +/** + * The outcome of a {@link DavLockTable.create}: the lock, the one that stopped + * it, or a full table. + * + * A union rather than a throw because this module is pure: the three outcomes + * are `200`/`201`, `423 no-conflicting-lock` and `503`, and choosing between + * them is the session's job, not this file's. + */ +export type DavLockGrant = + | { kind: "granted"; lock: DavLock } + | { kind: "conflict"; lock: DavLock } + | { kind: "full" }; + +// --------------------------------------------------------------------------- +// the table +// --------------------------------------------------------------------------- + +export interface DavLockTableOptions { + /** Lease for a `LOCK` that asked for nothing, in seconds. Default + * {@link DEFAULT_LOCK_TIMEOUT_SECONDS}. */ + defaultTimeoutSeconds?: number; + /** Longest lease granted, in seconds, and what `Timeout: Infinite` becomes. + * Default {@link MAX_LOCK_TIMEOUT_SECONDS}. */ + maxTimeoutSeconds?: number; + /** Most live locks at once. Default {@link MAX_LOCKS}. */ + maxLocks?: number; + /** + * The token minter. Default `urn:uuid:` + `randomUUID()`. + * + * The one impure default in this file, and it is an option for the same + * reason `S3SessionOptions.now` is: a deterministic minter makes a whole + * `LOCK` reply — headers, `locktoken`, `lockdiscovery` — a fixture that can + * be compared byte for byte. + */ + newToken?: () => string; +} + +/** + * Every write lock one share holds. + * + * ```ts + * const locks = new DavLockTable(); + * const grant = locks.create( + * { path: "/notes", collection: true, depth: "infinity", exclusive: true }, + * Date.now(), + * ); + * // grant.kind === "granted" → grant.lock.token goes in `Lock-Token` + * ``` + */ +export class DavLockTable { + readonly #locks = new Map(); + readonly #defaultTimeout: number; + readonly #maxTimeout: number; + readonly #maxLocks: number; + readonly #newToken: () => string; + + constructor(options: DavLockTableOptions = {}) { + this.#defaultTimeout = options.defaultTimeoutSeconds ?? DEFAULT_LOCK_TIMEOUT_SECONDS; + this.#maxTimeout = options.maxTimeoutSeconds ?? MAX_LOCK_TIMEOUT_SECONDS; + this.#maxLocks = options.maxLocks ?? MAX_LOCKS; + this.#newToken = options.newToken ?? (() => `${LOCK_TOKEN_PREFIX}${randomUUID()}`); + } + + /** Live locks, expired ones swept first — so this is what a client could + * still be holding, not what was ever taken. */ + size(now: number): number { + this.#sweep(now); + return this.#locks.size; + } + + /** + * Every lock still alive at `now`, in the order they were granted. + * + * For tests and for a caller that wants to see the whole table; nothing in + * the protocol asks for it. + */ + all(now: number): DavLock[] { + this.#sweep(now); + return [...this.#locks.values()]; + } + + /** + * The locks whose **scope covers** this path: one rooted exactly here, and + * every depth-infinity lock rooted above it (§6.1 point 4, §7.4). + * + * This is the answer to "is this resource locked" for a request that changes + * the resource itself — its bytes, or its properties — and it is what + * `lockdiscovery` reports (§15.8), which is why an indirectly locked member + * shows the ancestor's lock rather than nothing. + */ + covering(path: string, now: number): DavLock[] { + this.#sweep(now); + const covering: DavLock[] = []; + for (const lock of this.#locks.values()) { + if (lock.path === path || (lock.depth === "infinity" && isPathInside(path, lock.path))) { + covering.push(lock); + } + } + return covering; + } + + /** + * The locks **rooted at or under** this path. + * + * The other direction, and it answers a different question: not "is this + * resource locked" but "does removing this subtree remove somebody's lock + * root". `DELETE` and `MOVE` need it for §6.1 point 8, and a depth-infinity + * `LOCK` needs it for §7.4's "collection containing member URLs identifying + * resources that are currently locked". + */ + within(path: string, now: number): DavLock[] { + this.#sweep(now); + const within: DavLock[] = []; + for (const lock of this.#locks.values()) { + if (isPathInside(lock.path, path)) { + within.push(lock); + } + } + return within; + } + + /** The lock this token names, if it is still alive. */ + find(token: string, now: number): DavLock | undefined { + this.#sweep(now); + return this.#locks.get(token); + } + + /** + * Is `path` inside the scope of this lock (§9.11: "the Request-URI MUST + * identify a resource within the scope of the lock")? + * + * The lock root itself always is; a member is only inside a depth-infinity + * one. Static because it is a fact about the record rather than about the + * table. + */ + static inScope(lock: DavLock, path: string): boolean { + return lock.path === path || (lock.depth === "infinity" && isPathInside(path, lock.path)); + } + + /** + * The lock that would stop this request, or `undefined` if the scope is + * clear (§9.10.5's compatibility table). + * + * Two locks conflict when their scopes overlap and at least one is exclusive; + * two shared locks never do, which is the entire point of a shared lock + * (§6.2). Overlap is checked **both ways**: up the tree through + * {@link DavLockTable.covering}, and — for a depth-infinity request only — + * down it through {@link DavLockTable.within}, because a depth-infinity lock + * over a subtree holding a conflicting lock is the case §7.4 makes a `423`. + */ + conflict(path: string, depth: LockDepth, exclusive: boolean, now: number): DavLock | undefined { + const candidates = + depth === "infinity" + ? [...this.covering(path, now), ...this.within(path, now)] + : this.covering(path, now); + return candidates.find((lock) => exclusive || lock.exclusive); + } + + /** + * Take a new lock, or say why not. + * + * The caller has already decided the request is legal — that the depth is one + * of two, that the lock type is `write`, that the resource exists or has just + * been created for it (§7.3). What is decided *here* is the compatibility + * table, the table's own cap, and the lease. + */ + create(request: DavLockRequest, now: number): DavLockGrant { + const conflict = this.conflict(request.path, request.depth, request.exclusive, now); + if (conflict !== undefined) { + return { kind: "conflict", lock: conflict }; + } + /* `size` swept, so the cap is measured against live locks only: a table + full of lapsed ones is not full. */ + if (this.size(now) >= this.#maxLocks) { + return { kind: "full" }; + } + const timeoutSeconds = this.#grantedTimeout(request.timeoutSeconds); + const lock: DavLock = { + token: this.#newToken(), + path: request.path, + collection: request.collection, + depth: request.depth, + exclusive: request.exclusive, + owner: request.owner, + timeoutSeconds, + expiresAt: now + timeoutSeconds * 1000, + }; + this.#locks.set(lock.token, lock); + return { kind: "granted", lock }; + } + + /** + * Restart a lock's lease (§9.10.2), and answer the record that replaced it — + * or `undefined` for a token that names no live lock. + * + * §6.6: "the timeout counter MUST be restarted if a refresh lock request is + * successful", and the new lease is the server's to choose again, which is + * why a refresh with no `Timeout` header goes back to the default rather than + * keeping whatever the last one was. Everything else about the lock — scope, + * owner, token — is untouched: a refresh is a new deadline, not a new lock. + */ + refresh( + token: string, + requested: number | "infinite" | undefined, + now: number, + ): DavLock | undefined { + const existing = this.find(token, now); + if (existing === undefined) { + return undefined; + } + const timeoutSeconds = this.#grantedTimeout(requested); + const refreshed: DavLock = { + ...existing, + timeoutSeconds, + expiresAt: now + timeoutSeconds * 1000, + }; + this.#locks.set(token, refreshed); + return refreshed; + } + + /** Delete one lock by token, and say whether there was one (`UNLOCK`, §9.11). */ + remove(token: string): boolean { + return this.#locks.delete(token); + } + + /** + * Seconds left on a lease, for the `timeout` element (§14.29: "the number of + * seconds remaining before a lock expires"). + * + * Rounded **down**, and never below zero: a client told `Second-1` has at + * most a second, which errs toward refreshing early. + */ + static remaining(lock: DavLock, now: number): number { + return Math.max(0, Math.floor((lock.expiresAt - now) / 1000)); + } + + /** + * The lease this server grants for what the client asked (§6.6, §10.7). + * + * Absent is the default; `Infinite` is the maximum, because an unbounded lock + * on a server with no way to break one is a resource nobody can recover; a + * number is honoured up to that maximum and floored at one second, since a + * zero-second lock would be granted and gone in the same reply. + */ + #grantedTimeout(requested: number | "infinite" | undefined): number { + if (requested === undefined) { + return this.#defaultTimeout; + } + if (requested === "infinite") { + return this.#maxTimeout; + } + return Math.max(1, Math.min(Math.trunc(requested), this.#maxTimeout)); + } + + /** Drop everything whose lease has lapsed at `now` (§6.6). */ + #sweep(now: number): void { + for (const [token, lock] of this.#locks) { + if (lock.expiresAt <= now) { + this.#locks.delete(token); + } + } + } +} diff --git a/src/webdav/protocol.ts b/src/webdav/protocol.ts new file mode 100644 index 0000000..75c0635 --- /dev/null +++ b/src/webdav/protocol.ts @@ -0,0 +1,1112 @@ +/** + * WebDAV's wire, with no driver and no socket: request lines and headers in, + * XML documents and refusals out. + * + * Everything here is pure and total. `session.ts` decides what an operation + * *means*; this file decides what the bytes said and what the bytes will say — + * the `Depth`, `Overwrite`, `Destination`, `Timeout`, `Lock-Token` and `If` + * headers, the URL ↔ driver-path mapping in both directions, the three request + * grammars, and the `multistatus`, `error` and lock documents. Sources are + * **RFC 4918** for the DAV parts and **RFC 9110** for the HTTP ones, named at + * the rule they justify. + * + * The one header with a grammar of its own rather than a value is `If` + * (§10.4.2): a disjunction of conjunctions over lock tokens and entity tags, + * each optionally about some *other* resource. {@link parseIf} takes it apart + * and nothing more — whether a condition is true needs a driver and a lock + * table, so `session.ts` evaluates what this file parsed. + * + * ## Namespaces, and the one thing this layer loses + * + * Documents go out with `DAV:` as the **default** namespace — + * `` with unprefixed children — rather than with the + * `D:` prefix RFC 4918's examples use. The two are the same document to a + * namespace-aware parser (§14 binds names to the namespace, never to a prefix), + * and the default form is what the shared encoder produces without a second + * spelling of every element name. + * + * Coming in, the XML parser this module borrows (`src/s3/xml.ts`) reports an + * element's **local name** and drops its prefix, which is exactly right for the + * grammar — `` and `` are one element — and lossy for + * one thing: a requested property in some *other* namespace, which Finder and + * Office both send. `Z:Win32CreationTime` comes back as `Win32CreationTime` + * in the `DAV:` namespace, inside the `404` propstat where the client is + * looking only at the status. It is a real deviation, it is bounded to + * properties this server does not have, and undoing it means a + * namespace-tracking parser — see `.agents/roadmap.md`. + */ + +import { normalizePath, splitPath } from "../path.ts"; +import { parseXml, XmlError, xmlDocument, type XmlNode } from "../s3/xml.ts"; +import { DAV_NS, MAX_XML_BYTES, statusLine, statusOf, XML_CONTENT_TYPE } from "./constants.ts"; +import { DavLockTable, type DavLock } from "./locks.ts"; + +// --------------------------------------------------------------------------- +// the request and the reply +// --------------------------------------------------------------------------- + +/** + * One request, as the transport hands it over. + * + * Headers are **lowercased, single-valued and already combined** — the shape + * `node:http` hands out — which the S3 gateway deliberately cannot use (SigV4 + * signs headers as they were sent) and WebDAV has no reason not to: nothing + * here is signed, and every header this protocol defines is a single value. + */ +export interface WebdavRequestHead { + /** The HTTP method, uppercase. */ + method: string; + /** The raw request target: `/a/b%20c`, still percent-encoded. */ + target: string; + /** Lowercase header names to their single combined value. */ + headers: Readonly>; +} + +/** + * A reply, before it reaches a socket. + * + * The body may be bytes, an async stream of them (a `GET`), or absent (a `204`, + * or any `HEAD`). Same shape and same reasoning as `S3StreamResponse`: a `GET` + * of a 5 GiB file is not a document to be built in memory. + */ +export interface WebdavResponse { + status: number; + /** Lowercase header names, single values. */ + headers: Record; + body?: Uint8Array | AsyncIterable; +} + +/** + * An empty request body, for a method that has none. + * + * Written as an iterator rather than an empty `async function*` for the same + * reason `src/s3/session.ts` writes its own that way: a generator that never + * yields has a lint rule to answer to, and this has nothing to say. + */ +export const NO_BODY: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: async () => ({ done: true as const, value: undefined }), + }), +}; + +// --------------------------------------------------------------------------- +// refusals +// --------------------------------------------------------------------------- + +/** + * A refusal thrown from inside an operation, for the one reply to render. + * + * The WebDAV twin of `src/s3/session.ts`'s `S3ErrorThrown`, and it exists for + * the same reason: a refusal is often several calls deep, and the + * exactly-one-reply discipline (`AGENTS.md`, invariant 9) is kept by the single + * catch in `WebdavSession.handleRequest` rather than by threading a result type + * through every helper. + * + * `condition` is a RFC 4918 §16 precondition/postcondition element name, which + * is the machine-readable half of a refusal: `403` alone says "no", and + * `403` with `propfind-finite-depth` says which rule was broken. + */ +export class DavFault extends Error { + readonly code = "ERR_WEBDAV_FAULT"; + readonly status: number; + /** A §16 condition element name, rendered inside ``. */ + readonly condition: string | undefined; + /** + * `href` children of the condition element. + * + * Two of §16's conditions carry them, and one *requires* one: + * `lock-token-submitted` "MUST contain at least one URL of a locked resource + * that prevented the request", and `no-conflicting-lock` may name the root of + * the lock that stopped a `LOCK`. Both spare the client a `PROPFIND` for + * `lockdiscovery` to find out which resource it was. + */ + readonly hrefs: readonly string[]; + /** Extra headers the refusal carries (`Allow`, `Content-Range`). */ + readonly headers: Record; + + constructor( + status: number, + options: { + condition?: string; + hrefs?: readonly string[]; + message?: string; + headers?: Record; + } = {}, + ) { + super(options.message ?? statusLine(status)); + this.name = "DavFault"; + this.status = status; + this.condition = options.condition; + this.hrefs = options.hrefs ?? []; + this.headers = options.headers ?? {}; + } +} + +/** Is this a {@link DavFault}? */ +export function isDavFault(error: unknown): error is DavFault { + return error instanceof DavFault; +} + +/** Shorthand for `throw refuse(409)` at the call sites that read better that way. */ +export function refuse( + status: number, + options?: { + condition?: string; + hrefs?: readonly string[]; + message?: string; + headers?: Record; + }, +): DavFault { + return new DavFault(status, options); +} + +/** + * The status a thrown value becomes. + * + * A {@link DavFault} carries its own; a driver error is looked up by errno + * (`constants.ts`); anything else is `500`, because an error this server cannot + * name is this server's fault. + */ +export function statusOfError(error: unknown): number { + if (isDavFault(error)) { + return error.status; + } + if (typeof error === "object" && error !== null) { + const code = (error as { code?: unknown }).code; + return statusOf(typeof code === "string" ? code : undefined); + } + return statusOf(undefined); +} + +/** + * Render a refusal as a reply. + * + * A refusal with a §16 condition gets an `` document, because that is + * the only way the condition reaches the client; one without gets **no body at + * all**. Deliberately not a prose page: a body a client will not read is a body + * that has to be framed, and `Content-Length: 0` is unambiguous for every + * method including `HEAD`. + */ +export function faultResponse(error: unknown): WebdavResponse { + const status = statusOfError(error); + const condition = isDavFault(error) ? error.condition : undefined; + const extra = isDavFault(error) ? error.headers : {}; + if (condition === undefined) { + return { status, headers: { ...extra, "content-length": "0" } }; + } + const hrefs = isDavFault(error) ? error.hrefs : []; + return xmlBody(status, encodeErrorDocument(condition, hrefs), extra); +} + +/** An XML document as a reply, with the length and content type it needs. */ +export function xmlBody( + status: number, + document: string, + extra: Record = {}, +): WebdavResponse { + const body = Buffer.from(document, "utf8"); + return { + status, + headers: { + ...extra, + "content-type": XML_CONTENT_TYPE, + "content-length": String(body.byteLength), + }, + body, + }; +} + +// --------------------------------------------------------------------------- +// paths and hrefs +// --------------------------------------------------------------------------- + +/** + * A request target as a driver path. + * + * The path is percent-decoded **per segment** and then normalized, which is the + * difference from the S3 gateway's decode-the-whole-path-once rule and it is + * the right one here: S3 keys really can contain a `/` (a key *is* a prefix), + * a POSIX name cannot, so a `%2F` inside a segment is not a separator and not a + * name — it is a request this server has no resource for, and it is refused + * rather than quietly read as a directory boundary. + * + * Refused with `400`: a malformed escape, a decoded segment holding a `/` or a + * NUL, and a target that is not a path at all. `.` and `..` are resolved by + * `normalizePath`, which clamps at the root — there is no traversal out of the + * driver, by construction rather than by check. + * + * The query string is dropped: no method here defines one, and a client that + * appends `?` is asking for the same resource. + * + * @throws {DavFault} `400` for anything that is not a resolvable path. + */ +export function parseTargetPath(target: string): string { + const withoutQuery = target.split("?", 1)[0] as string; + const withoutFragment = withoutQuery.split("#", 1)[0] as string; + if (!withoutFragment.startsWith("/")) { + throw refuse(400, { message: `the request target ${target} is not an absolute path` }); + } + const decoded: string[] = []; + for (const raw of withoutFragment.split("/")) { + if (raw === "") { + continue; + } + const segment = decodeSegment(raw); + if (segment === undefined || segment.includes("/") || segment.includes("\0")) { + throw refuse(400, { message: `the request target ${target} does not name a resource` }); + } + decoded.push(segment); + } + return normalizePath(`/${decoded.join("/")}`); +} + +/** Percent-decode one segment, or `undefined` for a malformed escape. */ +function decodeSegment(value: string): string | undefined { + if (!value.includes("%")) { + return value; + } + try { + return decodeURIComponent(value); + } catch { + /* `decodeURIComponent` throws `URIError` for `%`, `%zz` and for an escape + sequence that is not valid UTF-8. All three are a malformed target. */ + return undefined; + } +} + +/** + * A driver path as the `href` that names it (RFC 4918 §8.3). + * + * Each segment is percent-encoded on its own, so a name containing a `/` — which + * this server never produces, but a driver could hand back — cannot become two + * segments. A collection's href ends in `/`, which §5.2 recommends and several + * clients rely on to tell a collection from a resource before reading + * `resourcetype`. + */ +export function hrefOf(path: string, collection: boolean): string { + const segments = splitPath(path).map((segment) => encodeURIComponent(segment)); + const href = `/${segments.join("/")}`; + return collection && !href.endsWith("/") ? `${href}/` : href; +} + +// --------------------------------------------------------------------------- +// headers +// --------------------------------------------------------------------------- + +/** What a `Depth` header can say (RFC 4918 §10.2). */ +export type Depth = 0 | 1 | "infinity"; + +/** + * Parse `Depth`, with the default this method applies when the header is + * absent. + * + * `undefined` — the invalid case — is a `400`, and it is separate from "absent" + * on purpose: §10.2 gives every method a default, and a client that *sent* + * `Depth: 2` sent something the protocol has no meaning for. + */ +export function parseDepth(value: string | undefined, fallback: Depth): Depth | undefined { + if (value === undefined) { + return fallback; + } + const depth = value.trim().toLowerCase(); + if (depth === "0") { + return 0; + } + if (depth === "1") { + return 1; + } + return depth === "infinity" ? "infinity" : undefined; +} + +/** + * Parse `Overwrite` (RFC 4918 §10.6): `T` or `F`, defaulting to `T`. + * + * `undefined` for anything else, which the caller answers `400`. + */ +export function parseOverwrite(value: string | undefined): boolean | undefined { + if (value === undefined) { + return true; + } + const flag = value.trim().toUpperCase(); + if (flag === "T") { + return true; + } + return flag === "F" ? false : undefined; +} + +/** + * The `Destination` header as a driver path (RFC 4918 §10.3). + * + * §10.3 requires an absolute URI, and clients send one — but an absolute *path* + * is what several of them send instead, and it names the same resource on the + * same origin, so both are accepted. What is **not** accepted is a destination + * on another host: §9.9.4 gives that `502`, and answering anything else would + * mean this server copying a resource somewhere it does not serve. + * + * `host` is the request's `Host` header. A request that arrived without one + * (HTTP/1.0) cannot have its destination's authority checked, so a destination + * carrying one is refused rather than assumed to be local. + * + * @throws {DavFault} `400` for a missing or unparseable header, `502` for + * another origin. + */ +export function parseDestination(value: string | undefined, host: string | undefined): string { + if (value === undefined || value.trim() === "") { + throw refuse(400, { message: "the Destination header is required" }); + } + const destination = value.trim(); + if (destination.startsWith("/")) { + return parseTargetPath(destination); + } + let url: URL; + try { + url = new URL(destination); + } catch { + throw refuse(400, { message: `the Destination header ${destination} is not a URI` }); + } + if (url.host === "" || host === undefined || url.host.toLowerCase() !== host.toLowerCase()) { + throw refuse(502, { + message: `the Destination ${destination} is not on this server`, + }); + } + return parseTargetPath(url.pathname); +} + +/** + * Parse `Timeout` (RFC 4918 §10.7): `Second-` or `Infinite`, as a + * comma-separated list in the client's order of preference. + * + * ``` + * TimeOut = "Timeout" ":" 1#TimeType + * TimeType = ("Second-" DAVTimeOutVal | "Infinite") + * DAVTimeOutVal = 1*DIGIT + * ``` + * + * **Only the first entry is read**, and that is a policy rather than a + * shortcut: this server clamps whatever it is asked for into its own range + * (`DavLockTable`) instead of refusing values it will not grant, so there is + * never a second entry to fall back to. `Timeout: Infinite, Second-4100000000` + * — the header §9.10.7's own example sends — therefore lands on `Infinite` and + * gets the server's maximum, which is what that example's server does with it + * too. + * + * `undefined` for an absent header **and** for one that does not parse: §6.6 + * makes the value a suggestion the server may ignore entirely, so a malformed + * suggestion is one more thing to ignore, not a request to refuse. A value past + * `2^32-1` — which §10.7 forbids — is read as the number it is and clamped by + * the table like any other. + */ +export function parseTimeout(value: string | undefined): number | "infinite" | undefined { + if (value === undefined) { + return undefined; + } + const first = (value.split(",", 1)[0] as string).trim(); + if (first.toLowerCase() === "infinite") { + return "infinite"; + } + const seconds = /^[Ss]econd-(\d+)$/.exec(first); + return seconds === null ? undefined : Number(seconds[1]); +} + +/** + * The token inside a `Lock-Token` header (RFC 4918 §10.5): a Coded-URL, + * ``, angle brackets and all. + * + * `undefined` for an absent header and for anything that is not a Coded-URL — + * `UNLOCK` answers `400` for both, which §9.11.1 spells out ("400 (Bad + * Request) - No lock token was provided"), because a token this server cannot + * read is a token it cannot delete. + */ +export function parseLockToken(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const coded = /^<([^<>]+)>$/.exec(value.trim()); + return coded === null ? undefined : (coded[1] as string); +} + +/** A token as the `Lock-Token` **response** header wants it: ``. */ +export function formatLockToken(token: string): string { + return `<${token}>`; +} + +// --------------------------------------------------------------------------- +// the If header +// --------------------------------------------------------------------------- + +/** + * One `Condition` of an `If` header (RFC 4918 §10.4.2): a state token or an + * entity tag, optionally negated. + * + * Exactly one of {@link IfCondition.token} and {@link IfCondition.etag} is + * present — the grammar has no third form, and a `Condition` is one or the + * other. + */ +export interface IfCondition { + /** `Not` was in front of it, so the condition is the negation (§10.4.3). */ + negated: boolean; + /** A `State-token`: the URI inside `<…>`, without the brackets. */ + token?: string; + /** An entity tag as sent, quotes and any `W/` kept: `"abc"`, `W/"abc"`. */ + etag?: string; +} + +/** + * One `List` of an `If` header, with the resource it is about. + * + * A list is a conjunction of its conditions, and the header is a disjunction of + * its lists (§10.4.3) — so this array is `OR` over `AND`. + */ +export interface IfList { + /** + * The driver path this list is about: the `Resource-Tag`'s, or `undefined` + * for an untagged list, which is about the request URI (§10.4.2). + */ + resource: string | undefined; + /** + * The tag named a resource this server does not serve — another origin, or a + * target that is not a path. + * + * Not a refusal: §10.4.4's "handling unmapped URLs" rule says to treat a URL + * with no resource as one that exists and has none of the state asked about, + * and a URL on somebody else's server is that case as far as this one can + * tell. So every plain condition against it is false and every negated one is + * true — which is exactly what a client using `` is relying on. + */ + foreign: boolean; + conditions: IfCondition[]; +} + +/** + * Parse an `If` header into its lists (RFC 4918 §10.4.2). + * + * ``` + * If = "If" ":" ( 1*No-tag-list | 1*Tagged-list ) + * No-tag-list = List + * Tagged-list = Resource-Tag 1*List + * List = "(" 1*Condition ")" + * Condition = ["Not"] (State-token | "[" entity-tag "]") + * State-token = Coded-URL + * Resource-Tag = "<" Simple-ref ">" + * ``` + * + * A `Resource-Tag` applies to every list after it until the next one, so the + * tag is carried forward rather than attached once. **Tagged and untagged lists + * are accepted in the same header** even though §10.4.2 says they cannot be + * mixed: the same section explains why that costs nothing — "the No-tag-list + * syntax is just a shorthand notation for a Tagged-list production with a + * Resource-Tag referring to the Request-URI" — so a mixed header has one + * unambiguous reading, and refusing it would buy strictness with interop. + * + * `host` is the request's `Host` header, used exactly as + * {@link parseDestination} uses it: a tag naming another authority is + * {@link IfList.foreign} rather than an error. + * + * `undefined` for a header that is not this grammar at all — an unbalanced + * `(`, a `<` with no `>`, a condition outside any list. The session answers + * `400`: §10.4 gives the header one syntax, and a header this server cannot + * read is one it cannot honour, which is the opposite of the `412` it would + * mean by guessing. + */ +export function parseIf(value: string, host: string | undefined): IfList[] | undefined { + const lists: IfList[] = []; + let tag: { resource: string | undefined; foreign: boolean } | undefined; + let at = 0; + while (at < value.length) { + const character = value[at] as string; + if (/\s/.test(character)) { + at++; + continue; + } + if (character === "<") { + /* At this depth a Coded-URL is a Resource-Tag; a state token only ever + appears inside a list, which the `(` branch consumes whole. */ + const close = value.indexOf(">", at + 1); + if (close === -1) { + return undefined; + } + tag = resourceTag(value.slice(at + 1, close), host); + at = close + 1; + continue; + } + if (character !== "(") { + return undefined; + } + const list = parseIfList(value, at); + if (list === undefined) { + return undefined; + } + lists.push({ + resource: tag?.resource, + foreign: tag?.foreign ?? false, + conditions: list.conditions, + }); + at = list.at; + } + return lists.length === 0 ? undefined : lists; +} + +/** + * A `Resource-Tag`'s URI as a driver path. + * + * `Simple-ref` (§8.3) is an absolute URI or an absolute path, which is the same + * pair {@link parseDestination} accepts — and the same reasoning applies to + * both, with one difference in the answer: a `Destination` on another host is a + * `502` because this server would have to write there, while an `If` tag on + * another host is merely a resource whose state this server cannot know + * (§10.4.4). + */ +function resourceTag( + reference: string, + host: string | undefined, +): { resource: string | undefined; foreign: boolean } { + const trimmed = reference.trim(); + try { + if (trimmed.startsWith("/")) { + return { resource: parseTargetPath(trimmed), foreign: false }; + } + const url = new URL(trimmed); + if (url.host === "" || host === undefined || url.host.toLowerCase() !== host.toLowerCase()) { + return { resource: undefined, foreign: true }; + } + return { resource: parseTargetPath(url.pathname), foreign: false }; + } catch { + /* An unparseable URI, or a path this server has no resource for + (`parseTargetPath`'s `400`). Either way it names nothing here. */ + return { resource: undefined, foreign: true }; + } +} + +/** One `"(" 1*Condition ")"`, from the `(` at `start`. */ +function parseIfList( + value: string, + start: number, +): { conditions: IfCondition[]; at: number } | undefined { + const conditions: IfCondition[] = []; + let at = start + 1; + let negated = false; + while (at < value.length) { + const character = value[at] as string; + if (/\s/.test(character)) { + at++; + continue; + } + if (character === ")") { + // A list must hold at least one condition: `1*Condition`. + return conditions.length === 0 ? undefined : { conditions, at: at + 1 }; + } + if (value.slice(at, at + 3).toLowerCase() === "not") { + negated = true; + at += 3; + continue; + } + if (character === "<") { + const close = value.indexOf(">", at + 1); + if (close === -1) { + return undefined; + } + conditions.push({ negated, token: value.slice(at + 1, close).trim() }); + negated = false; + at = close + 1; + continue; + } + if (character !== "[") { + return undefined; + } + const close = entityTagEnd(value, at + 1); + if (close === -1) { + return undefined; + } + conditions.push({ negated, etag: value.slice(at + 1, close).trim() }); + negated = false; + at = close + 1; + } + // Ran off the end with the list still open. + return undefined; +} + +/** + * The `]` closing an entity tag, skipping any inside the quoted part. + * + * An `entity-tag`'s opaque value is a quoted string and RFC 9110 §8.8.3 lets it + * hold a `]`; §10.4.2 forbids whitespace between the brackets but says nothing + * about that. Scanning through the quotes costs one flag and means an ETag this + * server never produces — but a client may have been handed by something else — + * does not truncate the header. + */ +function entityTagEnd(value: string, from: number): number { + let quoted = false; + for (let at = from; at < value.length; at++) { + const character = value[at]; + if (character === `"`) { + quoted = !quoted; + continue; + } + if (character === "]" && !quoted) { + return at; + } + } + return -1; +} + +/** + * Every state token the header submitted, in order and without duplicates. + * + * §10.4.1's second purpose, and it is deliberately **independent of + * evaluation**: "a state token counts as being submitted independently of + * whether the server actually has evaluated the state list it appears in, and + * also independently of whether or not the condition it expressed was found to + * be true". A token under `Not` is the one exclusion, and it is not an + * exception to that rule but a reading of what the client said: `Not ` + * asserts the resource is *not* held by it, which is the opposite of claiming + * to hold it — and it is how §10.4.8's `(Not )` idiom stays a + * tautology rather than a claim on a lock. + */ +export function submittedTokens(lists: readonly IfList[]): string[] { + const tokens: string[] = []; + for (const list of lists) { + for (const condition of list.conditions) { + if ( + condition.token !== undefined && + !condition.negated && + !tokens.includes(condition.token) + ) { + tokens.push(condition.token); + } + } + } + return tokens; +} + +// --------------------------------------------------------------------------- +// request bodies +// --------------------------------------------------------------------------- + +/** + * Read a request body into memory, refusing one over `limit`. + * + * Only the two XML grammars come through here; `PUT` streams (`session.ts`). + * Each chunk is **copied** on the way in, because a transport is free to reuse + * the buffer it handed over the moment the `await` returns (`AGENTS.md`, + * invariant 12). + * + * @throws {DavFault} `413` past the limit. + */ +export async function collectBody( + body: AsyncIterable, + limit: number = MAX_XML_BYTES, +): Promise { + const chunks: Uint8Array[] = []; + let total = 0; + for await (const chunk of body) { + total += chunk.byteLength; + if (total > limit) { + throw refuse(413, { message: `the request body is over the ${limit}-byte budget` }); + } + chunks.push(Uint8Array.prototype.slice.call(chunk)); + } + const buffer = new Uint8Array(total); + let at = 0; + for (const chunk of chunks) { + buffer.set(chunk, at); + at += chunk.byteLength; + } + return buffer; +} + +/** What a `PROPFIND` body asked for (RFC 4918 §9.1, §14.2, §14.20, §14.21). */ +export type PropfindRequest = + | { kind: "allprop" } + | { kind: "propname" } + | { kind: "prop"; names: string[] }; + +/** + * Parse a `PROPFIND` body. + * + * **An empty body is `allprop`**, which §9.1 requires ("A client may choose not + * to submit a request body ... treat as if it were an `allprop` request") and + * which is what `curl -X PROPFIND` sends. + * + * `` is parsed and folded into `allprop`: it names properties a server + * may leave out of `allprop`, and this server leaves none out that it has, so + * the answer is the same document either way. + * + * @throws {DavFault} `400` for a body that is not a well-formed `propfind`. + */ +export function parsePropfind(body: Uint8Array): PropfindRequest { + if (body.byteLength === 0) { + return { kind: "allprop" }; + } + const root = parseDocument(body, "propfind"); + const names: string[] = []; + let sawProp = false; + for (const child of root.children) { + if (child.name === "propname") { + return { kind: "propname" }; + } + if (child.name === "allprop") { + return { kind: "allprop" }; + } + if (child.name === "prop") { + sawProp = true; + for (const property of child.children) { + if (!names.includes(property.name)) { + names.push(property.name); + } + } + } + } + if (!sawProp) { + throw refuse(400, { message: "a propfind body must hold propname, allprop or prop" }); + } + return { kind: "prop", names }; +} + +/** One `` instruction: a property name and the value it was given. */ +export interface ProppatchSet { + name: string; + /** + * The element's text content, which is the whole value for every property + * this server can store — `getlastmodified` is an `HTTP-date` (§15.7) and + * nothing else is settable. A property whose value is *markup* keeps only its + * text here, which is enough to refuse it and not enough to store it; that is + * the same limit as "no dead properties" seen from the parser. + */ + text: string; +} + +/** + * The properties a `PROPPATCH` body wants written or removed (RFC 4918 §9.2). + * + * Both lists are kept whether or not this server can act on them: the reply has + * to name **every** property the request did, each with its own status, and a + * `remove` that vanished from the reply would be a `207` that silently agreed + * to it. + * + * The order is the document's, which §9.2 makes normative ("servers MUST + * process PROPPATCH instructions in document order (an exception to the normal + * rule that ordering is irrelevant)"). + */ +export interface ProppatchRequest { + /** Property names and values under ``, in request order. */ + set: ProppatchSet[]; + /** Property names under ``, in request order. */ + remove: string[]; +} + +/** + * Parse a `PROPPATCH` body. + * + * @throws {DavFault} `400` for a body that is not a well-formed `propertyupdate`. + */ +export function parseProppatch(body: Uint8Array): ProppatchRequest { + const root = parseDocument(body, "propertyupdate"); + const set: ProppatchSet[] = []; + const remove: string[] = []; + for (const child of root.children) { + const setting = child.name === "set"; + if (!setting && child.name !== "remove") { + continue; + } + for (const prop of child.children) { + if (prop.name !== "prop") { + continue; + } + for (const property of prop.children) { + if (setting) { + set.push({ name: property.name, text: property.text }); + } else { + remove.push(property.name); + } + } + } + } + if (set.length === 0 && remove.length === 0) { + throw refuse(400, { message: "a propertyupdate body must name at least one property" }); + } + return { set, remove }; +} + +/** What a `LOCK` body asked for (RFC 4918 §9.10, §14.11). */ +export interface LockInfoRequest { + /** `` rather than `` (§14.13). */ + exclusive: boolean; + /** + * The `` element, preserved whole, or `undefined` when the body had + * none — §14.11 makes it optional and §9.10.1 requires a server that gets one + * to keep it. + */ + owner: XmlNode | undefined; +} + +/** + * Parse a `LOCK` body, or answer `undefined` for the empty one that means + * "refresh" (RFC 4918 §7.7, §9.10.2). + * + * The two forms are the whole method: a body creates a lock, and **no body + * refreshes one** — "a server receiving a LOCK request with no body MUST NOT + * create a new lock". The distinction is the body's presence, so an empty body + * is not an error here and the session reads it as the refresh it is. + * + * `` must be ``: it is the only type RFC 4918 defines + * (§14.15, §7), and one this server does not have is refused rather than + * granted as a write lock the client did not ask for. `` must be + * one of the two §14.13 names. + * + * @throws {DavFault} `400` for a body that is not a well-formed `lockinfo`, and + * for a lock type or scope this server has no meaning for. + */ +export function parseLockInfo(body: Uint8Array): LockInfoRequest | undefined { + if (body.byteLength === 0) { + return undefined; + } + const root = parseDocument(body, "lockinfo"); + let exclusive: boolean | undefined; + let write = false; + let owner: XmlNode | undefined; + for (const child of root.children) { + if (child.name === "lockscope") { + exclusive = child.children.some((scope) => scope.name === "exclusive") + ? true + : child.children.some((scope) => scope.name === "shared") + ? false + : undefined; + } else if (child.name === "locktype") { + write = child.children.some((type) => type.name === "write"); + } else if (child.name === "owner") { + owner = toXmlNode(child); + } + } + if (exclusive === undefined) { + throw refuse(400, { message: "a lockinfo body needs a lockscope of exclusive or shared" }); + } + if (!write) { + throw refuse(400, { message: "write is the only lock type this server has (RFC 4918 §7)" }); + } + return { exclusive, owner }; +} + +/** + * A parsed element as an encodable one, so an `` can go back out the way + * it came in (§9.10.1). + * + * Lossy in exactly one way, and it is this layer's known namespace loss rather + * than a new one: the parser reports local names, so an owner written with a + * prefix bound to some other namespace comes back in `DAV:` (see the module + * docs). Mixed content is flattened the same way the parser flattens it — every + * text run of an element concatenated ahead of its children — which is enough + * for the `` and `a name` forms + * clients actually send. + */ +function toXmlNode(element: ReturnType): XmlNode { + return { + name: element.name, + text: element.text === "" ? undefined : element.text, + children: element.children.map((child) => toXmlNode(child)), + }; +} + +/** + * Parse a body and check its root element. + * + * Every refusal the XML layer can produce — too large, not UTF-8, malformed, a + * DOCTYPE, an entity, too deep — becomes one `400`. WebDAV has no equivalent of + * S3's per-refusal error codes, so the distinction has nowhere to go on the + * wire; the reason survives in the message. + * + * @throws {DavFault} `400`. + */ +function parseDocument(body: Uint8Array, root: string): ReturnType { + let parsed: ReturnType; + try { + parsed = parseXml(body, { maxBytes: MAX_XML_BYTES }); + } catch (error) { + if (error instanceof XmlError) { + throw refuse(400, { message: `the request body is not usable XML: ${error.message}` }); + } + /* v8 ignore next 2 -- `parseXml` documents `XmlError` as the only thing it + throws; this keeps that a fact rather than an assumption. */ + throw error; + } + if (parsed.name !== root) { + throw refuse(400, { message: `expected a ${root} document, got ${parsed.name}` }); + } + return parsed; +} + +// --------------------------------------------------------------------------- +// response documents +// --------------------------------------------------------------------------- + +/** One ``: a set of properties that share a status. */ +export interface Propstat { + status: number; + /** The property elements, already built. */ + props: XmlNode[]; + /** A §16 condition element for the `` inside this propstat. */ + condition?: string; +} + +/** + * One `` in a `multistatus`. + * + * Either form of §14.24: `propstat` (what `PROPFIND` and `PROPPATCH` answer) + * **or** a bare `status` (what a partly failed `DELETE` or `COPY` answers). + * Both at once is not a document RFC 4918 defines, and the encoder writes + * `propstat` when it is there. + */ +export interface MultistatusEntry { + href: string; + propstat?: Propstat[]; + status?: number; +} + +/** Encode a `` document (RFC 4918 §14.16). */ +export function encodeMultistatus(entries: readonly MultistatusEntry[]): string { + return xmlDocument( + { + name: "multistatus", + children: entries.map((entry) => ({ + name: "response", + children: [ + { name: "href", text: entry.href }, + ...(entry.propstat ?? []).map((propstat) => ({ + name: "propstat", + children: [ + { name: "prop", children: propstat.props }, + { name: "status", text: statusLine(propstat.status) }, + propstat.condition === undefined + ? undefined + : { name: "error", children: [{ name: propstat.condition }] }, + ], + })), + entry.status === undefined + ? undefined + : { name: "status", text: statusLine(entry.status) }, + ], + })), + }, + { xmlns: DAV_NS }, + ); +} + +/** + * Encode an `` document carrying one §16 condition (RFC 4918 §14.5), + * with the `href`s that condition names. + * + * `lock-token-submitted` "MUST contain at least one URL of a locked resource + * that prevented the request" (§16), so the hrefs are children of the condition + * element rather than siblings — the shape §7.5.2's example shows. + */ +export function encodeErrorDocument(condition: string, hrefs: readonly string[] = []): string { + return xmlDocument( + { + name: "error", + children: [ + { name: condition, children: hrefs.map((href) => ({ name: "href", text: href })) }, + ], + }, + { xmlns: DAV_NS }, + ); +} + +// --------------------------------------------------------------------------- +// lock documents +// --------------------------------------------------------------------------- + +/** + * The `supportedlock` property (RFC 4918 §15.10): one `lockentry` per + * combination of scope and type this server will grant. + * + * Both of §14.13's scopes over §14.15's one type, which is every lock RFC 4918 + * defines — and it is a *listing of what a `LOCK` here would accept* rather + * than a constant: the two entries are exactly what `parseLockInfo` lets + * through, so this cannot advertise a lock the next request would be refused + * (`AGENTS.md`, invariant 5). + */ +export function supportedLockNode(): XmlNode { + return { + name: "supportedlock", + children: [ + { + name: "lockentry", + children: [ + { name: "lockscope", children: [{ name: "exclusive" }] }, + { name: "locktype", children: [{ name: "write" }] }, + ], + }, + { + name: "lockentry", + children: [ + { name: "lockscope", children: [{ name: "shared" }] }, + { name: "locktype", children: [{ name: "write" }] }, + ], + }, + ], + }; +} + +/** + * One `` (RFC 4918 §14.1). + * + * Child order is the DTD's — `(lockscope, locktype, depth, owner?, timeout?, + * locktoken?, lockroot)` — which is **not** the order §9.10.7's example writes + * them in; the examples put `locktype` first, and the declaration is the + * normative half. Every optional child is sent: the token because §6.5 allows + * it and a client that lost its `Lock-Token` header can only recover it here, + * the owner because §9.10.1 requires this server to have preserved it, and the + * timeout because it is the only place the *granted* lease is stated. + */ +export function activeLockNode(lock: DavLock, now: number): XmlNode { + return { + name: "activelock", + children: [ + { name: "lockscope", children: [{ name: lock.exclusive ? "exclusive" : "shared" }] }, + { name: "locktype", children: [{ name: "write" }] }, + { name: "depth", text: String(lock.depth) }, + lock.owner, + { name: "timeout", text: `Second-${DavLockTable.remaining(lock, now)}` }, + { + name: "locktoken", + children: [{ name: "href", text: lock.token }], + }, + { + name: "lockroot", + children: [{ name: "href", text: hrefOf(lock.path, lock.collection) }], + }, + ], + }; +} + +/** + * The `lockdiscovery` property (RFC 4918 §15.8): every lock covering a + * resource, or the empty element when none do. + * + * "If there are no locks, but the server supports locks, the property will be + * present but contain zero 'activelock' elements" — which is why this is + * answered for every resource rather than left out, and why it looks the same + * as the truthfully-empty element this server sent before it had locks at all. + */ +export function lockDiscoveryNode(locks: readonly DavLock[], now: number): XmlNode { + return { + name: "lockdiscovery", + children: locks.map((lock) => activeLockNode(lock, now)), + }; +} + +/** + * The body of a successful `LOCK`: `…` + * (RFC 4918 §9.10.1). + * + * "MUST contain a body with the value of the DAV:lockdiscovery property in a + * prop XML element ... the full information about the lock just granted, while + * information about other (shared) locks is OPTIONAL" — so what goes back is + * the one lock this request took or refreshed, and not whatever else is on the + * resource. + */ +export function encodeLockResponse(lock: DavLock, now: number): string { + return xmlDocument( + { name: "prop", children: [lockDiscoveryNode([lock], now)] }, + { xmlns: DAV_NS }, + ); +} diff --git a/src/webdav/server.ts b/src/webdav/server.ts new file mode 100644 index 0000000..6e588fc --- /dev/null +++ b/src/webdav/server.ts @@ -0,0 +1,605 @@ +/** + * The WebDAV server's socket: `node:http`, and the only file in `src/webdav/` + * that imports it. + * + * Everything below it — the documents, the header grammars, the session — is a + * request in and a reply out, which is what makes the whole protocol testable + * with no listener and no client. This file is where HTTP happens: request line + * and headers in, status and headers out, and a body that may be a stream in + * either direction. + * + * ```ts + * import { createWebdavServer } from "mountx/webdav"; + * import { createMemoryDriver } from "mountx/drivers/memory"; + * + * await using server = await createWebdavServer(createMemoryDriver()).listen(); + * // rclone ls :webdav: --webdav-url $url + * ``` + * + * ## Who may connect + * + * The same rule as the S3 gateway's, for the same reason and with the same + * literal-address check (`src/s3/server.ts` sets out the reasoning in full): + * + * - **No `credentials`** — every request is served unauthenticated, and the + * bind is **loopback-only**. A non-loopback `host` is refused by + * {@link createWebdavServer} itself, before a socket exists, with a + * {@link WebdavBindError}. That includes `0.0.0.0` and `::`, which are the + * dangerous ones: they bind *every* interface. + * - **With `credentials`** — every request is authenticated with HTTP Basic, + * and any `host` is allowed. Basic sends a recoverable password on every + * request, so anything but a trusted network wants TLS in front — this server + * speaks plain HTTP and does not pretend otherwise. + * + * ## Bodies + * + * Requests stream in: `IncomingMessage` *is* an `AsyncIterable`, so it + * is handed to the session as it stands, and a body the session buffers is + * copied chunk by chunk on the way in (`AGENTS.md`, invariant 12). A body the + * session did **not** read — a `DELETE` that carried one, a request refused + * before its body mattered — is drained here rather than left in the socket, + * because an unread body and a keep-alive connection cannot both survive. + * + * Replies stream out with backpressure, and a client that walks away + * mid-download ends the iteration and calls the body's `return()`, which is + * what closes the file handle the `GET` generator opened. A body that does not + * match its `Content-Length` takes the connection with it rather than leaving a + * message HTTP cannot terminate — see `#outOfFrame`, which is + * `src/s3/server.ts`'s and is here for the same reason: `ServerResponse` does + * not check this itself, and a short body wedges the client or, worse, gets + * read as the head of the next reply. + * + * ## Not one file with `src/s3/server.ts` + * + * The two are the same shape — track connections, drain on `close()`, write one + * reply — and they are deliberately not shared yet: the bind refusal's wording, + * the error reply a failed handler falls back to, and the authentication are + * each transport's own, and unifying two implementations tends to produce a + * parameterised third. If a fourth HTTP-shaped transport appears, this is the + * duplication to remove first (`.agents/roadmap.md`). + */ + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import type { FsDriver } from "../types.ts"; +import { faultResponse, type WebdavResponse } from "./protocol.ts"; +import { WebdavSession, type WebdavSessionOptions } from "./session.ts"; + +/** The address a server binds when `host` is not given. */ +export const DEFAULT_HOST = "127.0.0.1"; + +/** + * How long {@link WebdavServer.close} lets in-flight responses finish, in + * milliseconds. Five seconds — a deadline rather than a wait, because a client + * streaming a large `GET` over a slow link would otherwise hold the process + * open for as long as it liked. + */ +export const DEFAULT_DRAIN_TIMEOUT = 5000; + +/** How often `close()` sweeps connections that have gone idle, in milliseconds. */ +const IDLE_SWEEP_MS = 25; + +// --------------------------------------------------------------------------- +// the bind refusal +// --------------------------------------------------------------------------- + +/** + * A bind this server will not perform. + * + * The **only** error type {@link createWebdavServer} throws for an address, + * which is what makes it catchable by name. Same shape as `S3BindError`. + */ +export class WebdavBindError extends Error { + readonly code = "ERR_WEBDAV_BIND"; + /** The host that was refused, exactly as it was passed. */ + readonly host: string; + + constructor(host: string, message: string) { + super(message); + this.name = "WebdavBindError"; + this.host = host; + } +} + +/** Is this a {@link WebdavBindError}? */ +export function isWebdavBindError(error: unknown): error is WebdavBindError { + return error instanceof WebdavBindError; +} + +/** + * Is this address literally a loopback address? + * + * Literally: no lookup, no resolver, no `/etc/hosts`. `localhost` on the + * convention, `::1` and every `127.x.y.z` on the address, and the `::ffff:` + * IPv4-mapped spelling because that is how a dual-stack listener writes it. + * Everything else — including the empty string, `0.0.0.0` and `::` — is not. + */ +export function isLoopbackHost(host: string): boolean { + const lower = host.toLowerCase(); + // Brackets come as a pair or not at all: `[::1]` is a spelling, `::1]` is a typo. + const bare = /^\[(.*)]$/.exec(lower)?.[1] ?? lower; + if (bare === "localhost" || bare === "::1") { + return true; + } + if (bare.startsWith("::ffff:")) { + return isLoopbackHost(bare.slice(7)); + } + const octets = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(bare); + return octets !== null && octets.slice(1).every((octet) => Number(octet) <= 255); +} + +/** + * Why this host may not be bound without credentials, or `undefined` if it may. + * + * Pure, and separate from the throw — the same treatment as + * `loopbackRefusal()`, `elevationRefusal()` and `ownershipRefusal()` elsewhere + * in this package: the sentence a user reads is a fact about the configuration + * and is worth testing on its own. + */ +export function bindRefusal(host: string, credentials: boolean): string | undefined { + if (credentials || isLoopbackHost(host)) { + return undefined; + } + const unspecified = host === "" || host === "0.0.0.0" || host === "::" || host === "[::]"; + return ( + `mountx: refusing to bind a WebDAV server to ${host === "" ? "every interface" : host} ` + + `with no credentials configured. ` + + (unspecified + ? `That address binds every interface, so the driver would be readable and writable ` + + `by anything that can reach this machine. ` + : `That address is reachable from outside this machine, and an unauthenticated ` + + `share would serve the driver to whatever finds it. `) + + `Pass \`credentials: { username, password }\` to bind it, or leave \`host\` at ` + + `${DEFAULT_HOST}.` + ); +} + +// --------------------------------------------------------------------------- +// options and the server +// --------------------------------------------------------------------------- + +export interface WebdavServerOptions extends WebdavSessionOptions { + /** Address to bind. Default {@link DEFAULT_HOST}. See the module docs on what + * a non-loopback one requires. */ + host?: string; + /** + * Port to listen on. Default `0` — an ephemeral port, which + * {@link WebdavServer.port} then reports. + * + * Not 80, not 8080: a library that squats a well-known port by default fails + * in the least useful way possible, on the machine that already runs + * something there. + */ + port?: number; + /** How long {@link WebdavServer.close} waits for in-flight responses, in + * milliseconds. Default {@link DEFAULT_DRAIN_TIMEOUT}. */ + drainTimeout?: number; + /** Called for transport-level failures: a socket error, a reply that could + * not be written. */ + onTransportError?: (error: unknown, peer: string | undefined) => void; +} + +/** A running (or runnable) WebDAV server. */ +export interface WebdavServer extends AsyncDisposable { + /** The session answering for it — its `stats` and its driver. */ + readonly session: WebdavSession; + /** The address it is bound to. */ + readonly host: string; + /** The port it is listening on, or the requested one before + * {@link WebdavServer.listen} (`0` for an ephemeral port). */ + readonly port: number; + /** The share's URL, with an IPv6 host bracketed. Meaningful once + * {@link WebdavServer.listen} has resolved. */ + readonly url: string; + /** Open connections. */ + readonly connections: number; + /** Start listening. Resolves once the port is bound. Idempotent. */ + listen(): Promise; + /** + * Stop accepting, let in-flight responses finish (bounded by + * `drainTimeout`), then drop every connection. Idempotent. + */ + close(): Promise; +} + +class WebdavServerImpl implements WebdavServer { + readonly session: WebdavSession; + readonly host: string; + + readonly #options: WebdavServerOptions; + readonly #server: Server; + readonly #drainTimeout: number; + /** Every open connection. */ + readonly #sockets = new Set(); + /** + * Connections with a reply still on them, by how many — a count rather than a + * flag because HTTP/1.1 allows pipelining, and a socket is idle only when the + * last outstanding reply has been answered. + */ + readonly #busy = new Map(); + #draining = false; + #port: number; + #listening: Promise | undefined; + #closed: Promise | undefined; + + constructor(driver: FsDriver, options: WebdavServerOptions) { + this.#options = options; + this.session = new WebdavSession(driver, options); + this.host = options.host ?? DEFAULT_HOST; + this.#port = options.port ?? 0; + this.#drainTimeout = options.drainTimeout ?? DEFAULT_DRAIN_TIMEOUT; + this.#server = createServer((request, response) => { + void this.#respond(request, response); + }); + this.#server.on("error", (error) => this.#report(error, undefined)); + this.#server.on("connection", (socket) => { + this.#sockets.add(socket); + socket.on("close", () => { + this.#sockets.delete(socket); + this.#busy.delete(socket); + }); + if (this.#draining) { + // Arrived after `close()` began; it will never be answered. + socket.destroy(); + } + }); + } + + get port(): number { + return this.#port; + } + + get url(): string { + return `http://${this.host.includes(":") ? `[${this.host}]` : this.host}:${this.#port}`; + } + + get connections(): number { + return this.#sockets.size; + } + + listen(): Promise { + this.#listening ??= new Promise((resolve, reject) => { + const onError = (error: unknown): void => reject(error as Error); + this.#server.once("error", onError); + this.#server.listen({ port: this.#port, host: this.host }, () => { + this.#server.off("error", onError); + const address = this.#server.address(); + /* v8 ignore next 3 -- a listening TCP server always reports an object; + the narrowing is what `address()`'s union requires. */ + if (address !== null && typeof address === "object") { + this.#port = address.port; + } + resolve(this); + }); + }); + return this.#listening; + } + + close(): Promise { + this.#closed ??= this.#close(); + return this.#closed; + } + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } + + async #close(): Promise { + if (this.#listening !== undefined) { + await this.#drain(); + } + } + + /** + * Stop accepting, drop every connection that is not answering something, and + * wait for the ones that are — up to `drainTimeout`. + * + * Idleness is tracked here rather than left to `closeIdleConnections()`, + * which does not count a connection that has been accepted but has not sent a + * request yet — the socket an HTTP agent opens to replace one it just dropped + * — and so makes `close()` wait out the whole deadline for a client that was + * never going to say anything. `closeAllConnections()` is still node's, at + * the deadline, where "everything, now" is exactly the semantics wanted. + */ + #drain(): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (): void => { + if (settled) { + return; + } + settled = true; + clearInterval(sweep); + clearTimeout(deadline); + resolve(); + }; + this.#draining = true; + this.#server.close(() => done()); + /* Now, again as each reply finishes (`#respond`), and on a timer as the + backstop for anything neither of those two saw. */ + this.#dropIdle(); + const sweep = setInterval(() => this.#dropIdle(), IDLE_SWEEP_MS); + const deadline = setTimeout(() => { + this.#server.closeAllConnections(); + done(); + }, this.#drainTimeout); + sweep.unref(); + deadline.unref(); + }); + } + + /** Destroy every connection with no reply outstanding on it. */ + #dropIdle(): void { + for (const socket of this.#sockets) { + if (!this.#busy.has(socket)) { + socket.destroy(); + } + } + } + + #report(error: unknown, peer: string | undefined): void { + this.#options.onTransportError?.(error, peer); + } + + async #respond(request: IncomingMessage, response: ServerResponse): Promise { + const socket = request.socket; + const peer = socket.remoteAddress; + /* A client that vanishes mid-request makes both of these emit rather than + throw, and an unhandled `error` event on either would take the process + with it. */ + request.on("error", (error: unknown) => this.#report(error, peer)); + response.on("error", (error: unknown) => this.#report(error, peer)); + this.#busy.set(socket, (this.#busy.get(socket) ?? 0) + 1); + response.on("close", () => { + const left = (this.#busy.get(socket) ?? 1) - 1; + if (left > 0) { + this.#busy.set(socket, left); + } else { + this.#busy.delete(socket); + if (this.#draining) { + this.#dropIdle(); + } + } + }); + const head = { + method: (request.method ?? "GET").toUpperCase(), + target: request.url ?? "/", + headers: request.headers as Readonly>, + }; + let reply: WebdavResponse; + try { + reply = await this.session.handleRequest(head, request); + } catch (error) { + /* v8 ignore start -- `handleRequest` is documented never to reject, and + asserts it of itself. The guard is the same one `src/nfs/server.ts` + keeps for `handleCall`: a broken invariant becomes one bad reply rather + than a dead process. */ + this.#report(error, peer); + reply = faultResponse(error); + /* v8 ignore stop */ + } + /* Whatever the session did or did not read, the rest of the body has to + leave the socket before the next request can be framed on it. */ + request.resume(); + await this.#write(response, reply, head.method, head.target, peer); + } + + /** + * A body that did not match the `Content-Length` above it kills the + * connection. Answers whether it did. See `src/s3/server.ts`'s `#outOfFrame`, + * which this is, for the full reasoning: a short body wedges a client that + * does not pipeline and is read as the head of the next reply by one that + * does, and both are worse than a broken connection. + * + * Reachable without a bug here: `streamHandle` ends the body on a short read, + * which is what a file truncated by another process between the `stat` that + * set the length and the reads that fill it looks like. + */ + #outOfFrame( + response: ServerResponse, + method: string, + target: string, + declared: number | undefined, + written: number, + peer: string | undefined, + ): boolean { + if (declared === undefined || written === declared) { + return false; + } + this.#report( + new Error( + `mountx: the reply to ${method} ${target} declared ${declared} bytes and produced ` + + `${written}. The connection was closed rather than left out of frame — the resource ` + + `changed size under the request.`, + ), + peer, + ); + response.destroy(); + return true; + } + + /** Write one reply, streaming its body if that is what it is. */ + async #write( + response: ServerResponse, + reply: WebdavResponse, + method: string, + target: string, + peer: string | undefined, + ): Promise { + const body = reply.body; + if (response.destroyed || response.writableEnded) { + // The client is already gone. Close the body so its handle goes with it. + await closeBody(body); + return; + } + response.statusCode = reply.status; + for (const [name, value] of Object.entries(reply.headers)) { + response.setHeader(name, value); + } + const declared = declaredLength(reply.headers); + if (body === undefined) { + /* No body to count. A `HEAD` gets here with the `Content-Length` it is + required to state and required not to fill (RFC 9110 §9.3.2), and node + knows not to frame it as a body. */ + response.end(); + return; + } + if (body instanceof Uint8Array) { + if (this.#outOfFrame(response, method, target, declared, body.byteLength, peer)) { + return; + } + response.end(Buffer.from(body.buffer, body.byteOffset, body.byteLength)); + return; + } + const iterator = body[Symbol.asyncIterator](); + let written = 0; + try { + for (;;) { + const step = await iterator.next(); + if (step.done === true) { + break; + } + /* Checked *after* the read rather than before the write, because a read + is where an abort usually lands: one chunk is the most this can fetch + for a client that is no longer there. */ + if (response.destroyed || response.writableEnded) { + return; + } + written += step.value.byteLength; + if (!response.write(step.value) && !(await drained(response))) { + return; + } + if (declared !== undefined && written > declared) { + // Already past the length promised; reading more only makes it worse. + break; + } + } + if (this.#outOfFrame(response, method, target, declared, written, peer)) { + return; + } + response.end(); + } catch (error) { + /* v8 ignore start -- the only body that streams is the `GET` generator, + whose reads are the session's to fail. If one ever does, the status + line is long gone and the truthful answer is a broken connection rather + than a second reply. */ + this.#report(error, peer); + response.destroy(); + /* v8 ignore stop */ + } finally { + /* The `GET` generator closes its file handle in a `finally`, which runs + on `return()` — this is the call that releases the descriptor of an + abandoned download. */ + await endIterator(iterator); + } + } +} + +/** The `Content-Length` a reply states, if it states one. */ +function declaredLength(headers: Record): number | undefined { + const value = headers["content-length"]; + if (value === undefined || !/^\d+$/.test(value)) { + return undefined; + } + return Number(value); +} + +/** + * Wait for a `drain`, and answer whether writing may continue. + * + * `false` for a response that closed or errored while we waited — a client that + * stopped listening, for which no drain is coming. + */ +function drained(response: ServerResponse): Promise { + return new Promise((resolve) => { + const settle = (value: boolean): void => { + response.off("drain", onDrain); + response.off("close", onGone); + response.off("error", onGone); + resolve(value); + }; + const onDrain = (): void => settle(true); + const onGone = (): void => settle(false); + response.once("drain", onDrain); + response.once("close", onGone); + response.once("error", onGone); + }); +} + +/** + * End a body nobody is going to read, releasing whatever it holds. + * + * **Stepped once before it is closed**: a generator suspended at its *start* + * runs no `finally` on `return()` — its body never began — so a `GET` reply + * that was built and then never written would close nothing and leak the + * descriptor the session opened. One `next()` puts the generator inside its + * `try`, and the `return()` after it runs the `finally`. + */ +async function closeBody(body: WebdavResponse["body"]): Promise { + if (body === undefined || body instanceof Uint8Array) { + return; + } + const iterator = body[Symbol.asyncIterator](); + try { + await iterator.next(); + } catch { + // A body that fails on its first step has already unwound its own `finally`. + } + await endIterator(iterator); +} + +/** + * Close an iterator and swallow whatever that costs: the part that mattered — + * the generator's `finally` — has already run, and the reply is either sent or + * unsendable. + */ +async function endIterator(iterator: AsyncIterator): Promise { + try { + await iterator.return?.(); + } catch { + // Deliberately ignored; see above. + } +} + +// --------------------------------------------------------------------------- +// the entry point +// --------------------------------------------------------------------------- + +/** + * Serve one driver over WebDAV. + * + * ```ts + * const server = createWebdavServer(driver, { + * credentials: { username: "ada", password: "…" }, + * host: "0.0.0.0", + * }); + * ``` + * + * Returns immediately; nothing is bound until {@link WebdavServer.listen}. What + * *is* checked here is the bind rule (see the module docs), because a refusal + * that waits for `listen()` is a refusal that has already opened a socket. + * + * @throws {WebdavBindError} for a non-loopback `host` with no `credentials`. + * @throws {TypeError} for a value that is not an `FsDriver`. + */ +export function createWebdavServer( + driver: FsDriver, + options: WebdavServerOptions = {}, +): WebdavServer { + if ( + typeof driver?.stat !== "function" || + typeof driver.readdir !== "function" || + typeof driver.open !== "function" + ) { + throw new TypeError( + `mountx: createWebdavServer() takes an FsDriver (with \`stat\`, \`readdir\` and ` + + `\`open\`), and was given something else.`, + ); + } + const host = options.host ?? DEFAULT_HOST; + const refusal = bindRefusal(host, options.credentials !== undefined); + if (refusal !== undefined) { + throw new WebdavBindError(host, refusal); + } + return new WebdavServerImpl(driver, options); +} diff --git a/src/webdav/session.ts b/src/webdav/session.ts new file mode 100644 index 0000000..aa41f4c --- /dev/null +++ b/src/webdav/session.ts @@ -0,0 +1,2094 @@ +/** + * The WebDAV session: one HTTP request in, one WebDAV reply out, and no socket. + * + * The same contract as every other session in this package (`AGENTS.md`, + * invariant 9): {@link WebdavSession.handleRequest} never rejects, and every + * thrown value becomes exactly one well-formed reply. `node:http` appears only + * in `server.ts`. What is different from the mount transports is what is + * *absent*: HTTP carries no handle table and no per-connection state, so there + * is no `PathLock` here and no subtree rewrite on `MOVE` — a request resolves + * its own paths and is done with them before it answers. That is the same + * reason `mountx/s3` has neither. + * + * ## What this server implements, exactly + * + * **RFC 4918 classes 1, 2 and 3.** `OPTIONS`, `HEAD`, `GET`, `PUT`, `DELETE`, + * `MKCOL`, `COPY`, `MOVE`, `PROPFIND`, `PROPPATCH`, `LOCK` and `UNLOCK`, over + * one driver. + * + * Class 2 is the write locks of §6 and §7 — both scopes, both depths, leases + * that lapse, and the *locked empty resource* a `LOCK` on an unmapped URL + * creates (§7.3). The table behind them is `src/webdav/locks.ts`, which is + * pure, synchronous and clockless; this file is where the clock enters + * (`options.now`) and where a lock meets a driver. `supportedlock` and + * `lockdiscovery` report what is really granted and really held, which is what + * makes the `DAV: 1, 2, 3` header a statement rather than a claim + * (`AGENTS.md`, invariant 5). + * + * The deliberate gaps, each because the driver interface has no answer for them + * rather than because they were forgotten: + * + * - **No dead properties.** A driver stores bytes and inode metadata; there is + * nowhere to keep an arbitrary XML property without inventing a sidecar file + * that would then show up in every listing. `PROPPATCH` therefore answers + * `403 cannot-modify-protected-property` for every property but one, which is + * the truthful answer for a server whose properties are all live and all + * derived. The exception is `getlastmodified`, which is live *and* writable + * because the driver interface has a call for it (`utimes`) — and only on a + * driver declaring the `times` capability. + * - **Conditional requests on `GET`, `HEAD` and `PUT` only.** RFC 9110's four + * — `If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since` — + * are evaluated there, by the same `src/http.ts` code `mountx/s3` uses over + * the same derived ETag. `DELETE`, `COPY` and `MOVE` ignore them: the header + * a WebDAV client reaches for on those is RFC 4918's `If`, which *is* + * answered, and a conditional honoured on three methods and silently dropped + * on six would be worse than one honoured where it is documented. + * - **`GET` of a collection is `405`.** A collection has no body in RFC 4918; + * the HTML index other servers answer with is a user interface, and + * `PROPFIND` is the protocol's own way to list one. + * + * ## Locks, and what a token is for + * + * A lock is state a *client* left behind: it survives the connection, the + * request and — up to its lease — the client itself. Three rules of §6 and §7 + * are worth having in front of you, because each one is a place this file does + * something that looks surprising: + * + * - **A lock never follows its resource** (§7.6). A `MOVE` of a lock root + * destroys the lock rather than carrying it, because §6.1 point 8 deletes any + * lock whose root became unmapped — so `#discardUnmapped` runs after every + * `DELETE`, `MOVE` and `COPY`, and checks each root rather than assuming it. + * - **A lock on an unmapped URL creates a real, empty file** (§7.3), which + * outlives the lock. RFC 2518's lock-null resources are the alternative that + * §7.3 permits and this server does not implement: a resource that is neither + * present nor absent has no representation in a driver that stores files. + * - **The token is the whole of ownership.** There is one principal here at + * most (`credentials`), so §6.4's "check that the authenticated principal + * matches the lock creator" reduces to holding the token — which is why + * `UNLOCK` needs nothing else, and why a token in an `If` header is proof. + * + * ## The `If` header, and the three refusals + * + * `If` (§10.4) is the other half of locking: it is how a request proves it + * holds a lock, and §10.4.1 insists its two purposes stay separate — it is a + * *precondition* that can fail, and it is a *submission* of every token in it + * whether or not the condition that carried them was true. `#guard` does both, + * once per request, and hands the mutating methods what was submitted. + * + * Which refusal a client gets says which of the two failed, and getting that + * pair the wrong way round is the classic way to make a WebDAV client retry + * forever: + * + * - **`412`** — the header was there and every state list evaluated false + * (§10.4.1). The client's state is stale; re-reading the resource is what + * fixes it. + * - **`423` with `lock-token-submitted`** — the request would change a + * write-locked resource and did not carry that lock's token (§7.5.2). The + * `href`s name the lock roots in the way, which §16 requires and which saves + * the client a `PROPFIND` for `lockdiscovery`. + * - **`207` with a `423` inside it** — the lock in the way is on a *member* of + * the tree the request named, not on the resource it named. §9.6.1 wants a + * multistatus for a failure on some other resource, and its own example is + * this one; nothing is deleted or moved when that happens. + * + * `GET`, `HEAD`, `PROPFIND` and `OPTIONS` are not lock-protected at all — §7 is + * explicit that "all other HTTP/WebDAV methods defined so far — GET in + * particular — function independently of a write lock" — but an `If` header on + * one of them is still a precondition, because §10.4 puts no method restriction + * on it. + * + * ## Symbolic links: followed for bytes, never walked + * + * WebDAV has no way to name a link, so a link is the resource it points at — + * `GET`, `PROPFIND` and a `stat` for properties all follow one. The two + * **recursive** operations do not, and both would be destructive if they did: + * + * - **`DELETE` removes the link**, which is why `#delete` takes an `lstat` and + * `#deleteTree` recurses on the `readdir` dirent rather than on a fresh + * `stat`. Following a link to a collection would empty out whatever it points + * at and leave the entry the client actually named sitting there. + * - **`COPY` reports a link to a collection (`403`) rather than descending into + * it.** A link back to any ancestor makes the walk revisit a subtree it is + * still writing into, and each pass creates the next one; there is no depth at + * which that stops being wrong. A link to a *file* is followed and its bytes + * are copied, because that cannot recur. The rule holds at the **top** of the + * tree as well as inside it, and that is the end where it does the most work: + * `COPY /link → /dst` with `/link → /` passes the destination-inside-source + * test — as text the two paths are unrelated — and then copies a tree it is + * writing into, forever. + * + * ## The properties this server has + * + * All live, all derived from one `stat` or from the lock table, and none of + * them stored: `creationdate`, `displayname`, `getcontentlength`, + * `getcontenttype`, `getetag`, `getlastmodified`, `resourcetype`, + * `supportedlock` (the two entries §15.10 defines) and `lockdiscovery` (the + * locks covering the resource, indirect ones included). RFC 4331's + * `quota-available-bytes` and `quota-used-bytes` are answered from `statfs` + * when a driver has one, and only when a request names them: RFC 4331 §3 keeps + * them out of `allprop`, and a driver without `statfs` answers `ENOSYS`, which + * is a `404` propstat rather than an invented number. + * + * The ETag is derived from the same inputs as the S3 gateway's — sha256 over + * `dev:ino:size:mtimeMs`, first 32 hex characters — without its + * multipart-shaped `-1` suffix, which is an S3 spelling and means nothing here. + * It is a strong validator in the RFC 9110 sense as far as the driver's own + * metadata goes: two writes within one millisecond that leave the size + * unchanged are indistinguishable to it, which is the same resolution limit + * `getlastmodified` has. + * + * ## Atomicity, stated rather than implied + * + * `PUT` writes in place, with no temporary file and no rename, for the reasons + * `src/s3/session.ts` sets out at length: the driver interface has no atomic + * create, `rename` is optional and only *declared* atomic, and a staging copy + * would be visible to every listing. The destination is opened at the **first + * byte of the body**, so a `PUT` refused before then leaves the resource + * exactly as it was; one that dies mid-body leaves what had been written. A + * `COPY` of a tree is not a transaction either — what succeeded stays — which + * is why a partial one answers `207` naming each failure rather than a single + * status that would describe neither half. + */ + +import { createHash, timingSafeEqual } from "node:crypto"; +import { createLoopback, type Loopback } from "../harness.ts"; +import { + etagMatchesWeakly, + evaluateConditionals, + formatETag, + formatHttpDate, + formatIsoDate, + parseETagList, + parseHttpDate, + parseRange, +} from "../http.ts"; +import { basename, dirname, isPathInside, joinPath } from "../path.ts"; +import type { FileHandleLike, FsDriver, StatsLike } from "../types.ts"; +import type { XmlNode } from "../s3/xml.ts"; +import { + ALLOW_HEADER, + COLLECTION_CONTENT_TYPE, + DAV_COMPLIANCE, + MAX_XML_BYTES, + MS_AUTHOR_VIA, + READ_CHUNK_BYTES, + RESOURCE_CONTENT_TYPE, +} from "./constants.ts"; +import { DavLockTable, type DavLock, type DavLockTableOptions } from "./locks.ts"; +import { + collectBody, + encodeLockResponse, + encodeMultistatus, + faultResponse, + formatLockToken, + hrefOf, + lockDiscoveryNode, + NO_BODY, + parseDepth, + parseDestination, + parseIf, + parseLockInfo, + parseLockToken, + parseOverwrite, + parsePropfind, + parseProppatch, + parseTargetPath, + parseTimeout, + refuse, + statusOfError, + submittedTokens, + supportedLockNode, + xmlBody, + type DavFault, + type Depth, + type IfCondition, + type IfList, + type MultistatusEntry, + type Propstat, + type ProppatchSet, + type WebdavRequestHead, + type WebdavResponse, +} from "./protocol.ts"; + +// --------------------------------------------------------------------------- +// properties +// --------------------------------------------------------------------------- + +/** + * The properties an `allprop` request answers with, in document order. + * + * RFC 4918 §9.1 lets a server leave expensive properties out of `allprop`; + * every one of these comes from a `stat` this session has already taken, so + * none are left out. RFC 4331's quota pair is **not** here, which §3 of that + * RFC requires. + */ +export const ALLPROP_NAMES = [ + "creationdate", + "displayname", + "getcontentlength", + "getcontenttype", + "getetag", + "getlastmodified", + "resourcetype", + "supportedlock", + "lockdiscovery", +] as const; + +/** The two RFC 4331 quota properties, answered only when a request names them. */ +export const QUOTA_NAMES = ["quota-available-bytes", "quota-used-bytes"] as const; + +/** + * The ETag for a resource: sha256 over `dev:ino:size:mtimeMs`, first 32 hex + * characters. + * + * The same inputs as `mountx/s3`'s derived ETag, without the `-1` suffix that + * makes an S3 client read it as a multipart tag. Derived rather than a digest + * of the bytes: hashing a 5 GiB resource to answer a `PROPFIND` is not a thing + * a server may do, and every input here is metadata the `stat` already carried. + */ +export function resourceETag(stats: StatsLike): string { + return createHash("sha256") + .update(`${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}`) + .digest("hex") + .slice(0, 32); +} + +// --------------------------------------------------------------------------- +// options +// --------------------------------------------------------------------------- + +/** A username and password for HTTP Basic authentication (RFC 7617). */ +export interface WebdavCredentials { + username: string; + password: string; +} + +export interface WebdavSessionOptions { + /** + * The one credential pair this server accepts. **Present means every request + * is authenticated**; absent means every request is served, which is only + * safe on a loopback bind and is what `server.ts` enforces. + * + * Basic over plain HTTP sends the password recoverably on every request. That + * is WebDAV's own default — it is the scheme every client implements — and it + * is the caller's job to put TLS or a loopback in front of it. + */ + credentials?: WebdavCredentials; + /** The realm named in `WWW-Authenticate`. Default `mountx`. */ + realm?: string; + /** Bytes per positional read when streaming a `GET`. Default {@link READ_CHUNK_BYTES}. */ + readChunkBytes?: number; + /** Largest XML request body accepted. Default {@link MAX_XML_BYTES}. */ + maxXmlBytes?: number; + /** + * Largest `PUT` body accepted, in bytes. **Default unlimited** — a gateway in + * front of a disk has no business inventing a smaller limit than the disk's, + * and a driver that runs out answers `ENOSPC`, which is already `507`. + */ + maxBodyBytes?: number; + /** + * The clock, in milliseconds. Default `Date.now`. + * + * The **one** impure default in this file, and the same boundary + * `S3SessionOptions.now` draws: a lock's lease is a fact about now, and every + * module below this one — `src/http.ts`, `src/webdav/locks.ts` — takes its + * time as an argument on purpose. Pass one and a whole lock lifecycle, + * expiry included, is deterministic. + */ + now?: () => number; + /** + * Lock-table policy: the default and maximum lease, the cap on live locks, + * and the token minter. See `src/webdav/locks.ts` for what each one costs. + */ + locks?: DavLockTableOptions; + /** Run the reply-exactly-once assertions. Default on outside production. */ + debug?: boolean; + /** Called for every request that ends in an error reply. */ + onError?: (error: unknown, head: WebdavRequestHead | undefined) => void; + /** Called when a dev-mode assertion fails. Default: collect in `assertions`. */ + onAssertion?: (message: string) => void; +} + +/** Counters, all cheap, all useful in a test. */ +export interface WebdavSessionStats { + /** Requests handed to {@link WebdavSession.handleRequest}. */ + requests: number; + /** Replies produced (successful or not). */ + replies: number; + /** Of which replies with a `4xx` or `5xx` status. */ + errors: number; + /** Per-method counts. */ + methods: Map; + /** Dev-mode assertion failures. Must be zero. */ + assertions: number; +} + +/** One resource a recursive walk could not deal with. */ +interface Failure { + path: string; + collection: boolean; + status: number; +} + +/** + * What a `PROPPATCH` decided about one property, before anything was written. + * + * `apply` is present exactly when `status` is `200`, and it is deliberately not + * called until every instruction has one of these: §9.2 makes the method atomic + * ("instructions MUST either all be executed or none executed"), so a request + * that names one property this server can store and one it cannot must store + * neither. + */ +interface PropertyOutcome { + name: string; + status: number; + /** A §16 condition for the propstat this outcome lands in. */ + condition?: string; + /** The write itself, run only if nothing in the request failed. */ + apply?: () => Promise; +} + +/** + * Group the outcomes into `propstat` blocks, in document order. + * + * §9.2.1's atomicity rule turned into a document: "note that if [200] appears + * for one property, it appears for every property in the response, due to the + * atomicity of PROPPATCH" — so when anything failed, the properties that *would* + * have succeeded become `424 Failed Dependency`, "the property change could not + * be made because of another property change that failed". One block per + * (status, condition) pair, because a `propstat` is defined as the properties + * that share a status. + */ +function propstatsOf(outcomes: readonly PropertyOutcome[], blocked: boolean): Propstat[] { + const propstats: Propstat[] = []; + for (const outcome of outcomes) { + const status = blocked && outcome.status === 200 ? 424 : outcome.status; + const condition = status === 424 ? undefined : outcome.condition; + const existing = propstats.find( + (propstat) => propstat.status === status && propstat.condition === condition, + ); + if (existing === undefined) { + propstats.push({ status, condition, props: [{ name: outcome.name }] }); + } else { + existing.props.push({ name: outcome.name }); + } + } + return propstats; +} + +/** + * What one request knows before it touches the driver: when it is being + * answered, and which lock tokens its `If` header submitted (§10.4.1). + * + * Built once per request in `#guard` and threaded through the mutating methods + * rather than recomputed, so that a `MOVE` weighing a lock at its source and + * another at its destination reads both against one moment — a lease that + * lapsed between the two checks would otherwise make the same request answer + * two different things about itself. + */ +interface Guard { + now: number; + submitted: Set; + /** + * The lists themselves, kept only for the one method that evaluates them + * itself: `LOCK` has a `412` of its own with a §16 condition on it + * (`lock-token-matches-request-uri`), and it is a better answer than the bare + * one §10.4 gives, so a refresh decides the order — is there a lock here at + * all, and only then, is the header true. + */ + lists: readonly IfList[] | undefined; +} + +/** The state an `If` condition is matched against (§10.4.4). */ +interface ResourceState { + /** Every lock token whose scope covers the resource. */ + tokens: readonly string[]; + /** Its entity tag, or `undefined` for a collection and for nothing at all. */ + etag: string | undefined; +} + +/** + * A resource this server cannot say anything about: an unmapped URL, or a + * tagged list naming another origin. + * + * §10.4.4 makes both the same case — "treat as if the URL identified a resource + * that exists but does not have the specified state" — so every plain condition + * against it is false and every negated one is true. + */ +const UNKNOWN_RESOURCE: ResourceState = { tokens: [], etag: undefined }; + +/** + * Does the resource have the state this condition describes, `Not` aside? + * + * A **state token** matches when it is one of the resource's, which for a lock + * token means "the resource is anywhere in the scope of the lock" (§10.4.4). + * An **entity tag** matches under RFC 9110 §8.8.3.2's *weak* comparison, which + * §10.4.4 explicitly leaves to the server ("servers MUST use either the weak or + * the strong comparison function"): §10.4.9's own example carries `[W/"A weak + * ETag"]` and expects it to match, and every tag this server mints is strong, + * so the weak function is the one that reads the RFC's examples the way they + * are written. + */ +function matchesCondition(condition: IfCondition, state: ResourceState): boolean { + if (condition.token !== undefined) { + return state.tokens.includes(condition.token); + } + return ( + state.etag !== undefined && + condition.etag !== undefined && + etagMatchesWeakly(parseETagList(condition.etag), state.etag) + ); +} + +// --------------------------------------------------------------------------- +// the session +// --------------------------------------------------------------------------- + +/** + * A WebDAV server over one driver, with no socket. + * + * ```ts + * const session = new WebdavSession(createMemoryDriver()); + * const reply = await session.handleRequest({ + * method: "PROPFIND", + * target: "/", + * headers: { depth: "1" }, + * }); + * ``` + */ +export class WebdavSession { + /** The driver, wrapped so paths are normalized and gaps answer `ENOSYS`. */ + readonly driver: Loopback; + /** + * Every write lock this share holds (RFC 4918 §6). + * + * Public because it is the only server state a caller can reasonably want to + * see — how many locks are out, and on what — and because a test that drives + * the lease has to be able to read it. It is the session's own: HTTP gives + * every request the same session, so unlike 9P's table there is no second + * connection to share it with. + */ + readonly locks: DavLockTable; + readonly options: WebdavSessionOptions; + readonly stats: WebdavSessionStats = { + requests: 0, + replies: 0, + errors: 0, + methods: new Map(), + assertions: 0, + }; + /** Dev-mode assertion failures, in order. Empty in a healthy session. */ + readonly assertions: string[] = []; + + readonly #readChunkBytes: number; + readonly #maxXmlBytes: number; + readonly #now: () => number; + readonly #debug: boolean; + /** Requests not answered yet, by internal ticket — see `S3Session`'s. */ + readonly #inflight = new Set(); + #nextTicket = 1; + + constructor(driver: FsDriver, options: WebdavSessionOptions = {}) { + this.driver = createLoopback(driver); + this.options = options; + this.#readChunkBytes = options.readChunkBytes ?? READ_CHUNK_BYTES; + this.#maxXmlBytes = options.maxXmlBytes ?? MAX_XML_BYTES; + this.#now = options.now ?? Date.now; + this.locks = new DavLockTable(options.locks); + this.#debug = options.debug ?? process.env.NODE_ENV !== "production"; + } + + /** + * Answer one request. **Never rejects**, and produces exactly one reply. + * + * A `HEAD` reply carries the headers of the `GET` it stands for — + * `Content-Length` included — and no body, which is RFC 9110 §9.3.2 rather + * than anything WebDAV says. + */ + async handleRequest( + head: WebdavRequestHead, + body: AsyncIterable = NO_BODY, + ): Promise { + this.stats.requests++; + const ticket = this.#nextTicket++; + if (this.#debug) { + this.#inflight.add(ticket); + } + let response: WebdavResponse | undefined; + try { + response = await this.#dispatch(head, body); + } catch (error) { + this.options.onError?.(error, head); + response = faultResponse(error); + } + /* v8 ignore next 3 -- structurally unreachable: one `handleRequest` call + answers once. The assertion is what makes that structural. */ + if (this.#debug && !this.#inflight.delete(ticket)) { + this.#assert(`${head.method} ${head.target} was answered twice`); + } + /* v8 ignore next 4 -- `#dispatch` returns or throws, and the catch answers + everything it throws. The assertion is the point: if that ever stops + being true, a caller must not be left waiting. */ + if (response === undefined) { + this.#assert(`${head.method} ${head.target} produced no reply`); + response = { status: 500, headers: { "content-length": "0" } }; + } + this.stats.replies++; + if (response.status >= 400) { + this.stats.errors++; + } + return head.method === "HEAD" && response.body !== undefined + ? { status: response.status, headers: response.headers } + : response; + } + + // ------------------------------------------------------------------------- + // dispatch + // ------------------------------------------------------------------------- + + async #dispatch( + head: WebdavRequestHead, + body: AsyncIterable, + ): Promise { + const method = head.method.toUpperCase(); + this.#count(method); + const unauthorized = this.#authorize(head); + if (unauthorized !== undefined) { + return unauthorized; + } + /* `OPTIONS *` asks about the server rather than about a resource (RFC 9110 + §9.3.7), so it is answered before the target is read as a path — which is + what `*` is not. */ + if (method === "OPTIONS") { + return this.#options(); + } + const path = parseTargetPath(head.target); + /* The `If` header is evaluated here, once, for every method that names a + resource — §10.4 puts no method restriction on it, and a `GET` whose + state lists all fail is as much a `412` as a `PUT`'s. What the *guard* + carries on to the mutating methods is the other half of §10.4.1: which + tokens were submitted. */ + const guard = await this.#guard(head, path, method !== "LOCK"); + switch (method) { + case "GET": + case "HEAD": { + return await this.#get(head, path); + } + case "PUT": { + return await this.#put(head, path, body, guard); + } + case "DELETE": { + return await this.#delete(head, path, guard); + } + case "MKCOL": { + return await this.#mkcol(path, body, guard); + } + case "COPY": + case "MOVE": { + return await this.#copyOrMove(head, path, method === "MOVE", guard); + } + case "PROPFIND": { + return await this.#propfind(head, path, body, guard); + } + case "PROPPATCH": { + return await this.#proppatch(path, body, guard); + } + case "LOCK": { + return await this.#lock(head, path, body, guard); + } + case "UNLOCK": { + return this.#unlock(head, path); + } + default: { + /* `REPORT`, `PATCH`, `SEARCH`, anything else: the `Allow` header is the + honest list, and a client reading it learns what this server has + without having to parse the `DAV` header. */ + throw refuse(405, { headers: { allow: ALLOW_HEADER } }); + } + } + } + + #count(method: string): void { + this.stats.methods.set(method, (this.stats.methods.get(method) ?? 0) + 1); + } + + #assert(message: string): void { + this.stats.assertions++; + this.assertions.push(message); + this.options.onAssertion?.(message); + } + + /** + * `401` for a request that did not prove who it is, or `undefined` to carry + * on. + * + * Basic (RFC 7617), and only Basic: it is what every WebDAV client + * implements, and a Digest implementation would need server state this + * session deliberately does not keep. The comparison is over sha256 digests + * rather than the strings, so it is constant-time in the *content* and + * carries no length to time — `timingSafeEqual` requires equal lengths, and + * padding to reach that is what leaks the length. + */ + #authorize(head: WebdavRequestHead): WebdavResponse | undefined { + const credentials = this.options.credentials; + if (credentials === undefined) { + return undefined; + } + const header = head.headers["authorization"]; + const supplied = header === undefined ? undefined : parseBasic(header); + const expected = `${credentials.username}:${credentials.password}`; + if (supplied !== undefined && digestEquals(supplied, expected)) { + return undefined; + } + this.options.onError?.(refuse(401), head); + const realm = (this.options.realm ?? "mountx").replaceAll(/["\\]/g, ""); + return { + status: 401, + headers: { + "www-authenticate": `Basic realm="${realm}", charset="UTF-8"`, + "content-length": "0", + }, + }; + } + + // ------------------------------------------------------------------------- + // OPTIONS + // ------------------------------------------------------------------------- + + /** + * What this server is, in headers (RFC 4918 §8.1, §10.1). + * + * Answered without touching the driver and without resolving the target: a + * client sends `OPTIONS` to decide whether to speak WebDAV at all, and + * failing it with a `404` for a path that does not exist yet — which is + * exactly what a client about to `PUT` is asking about — would end the + * conversation before it started. + */ + #options(): WebdavResponse { + return { + status: 200, + headers: { + dav: DAV_COMPLIANCE, + allow: ALLOW_HEADER, + "ms-author-via": MS_AUTHOR_VIA, + "accept-ranges": "bytes", + "content-length": "0", + }, + }; + } + + // ------------------------------------------------------------------------- + // GET / HEAD + // ------------------------------------------------------------------------- + + async #get(head: WebdavRequestHead, path: string): Promise { + const stats = await this.#stat(path); + if (stats.isDirectory()) { + throw refuse(405, { + headers: { allow: ALLOW_HEADER }, + message: "a collection has no body; use PROPFIND to list it", + }); + } + if (!stats.isFile()) { + /* A FIFO, a socket or a device node — nameable in a driver, and not + something HTTP can transfer. The memory driver is the one that makes + these reachable at all (`mountx.mknod`). */ + throw refuse(403, { message: "that resource is not a regular file" }); + } + /* Before the `Range`, which RFC 9110 §13.2.2 requires: a `304` and a `412` + are both answers about the whole representation, and evaluating the range + first would answer `206` to a request whose precondition failed. */ + const conditional = this.#conditional(head, stats); + if (conditional === 304) { + /* §15.4.5: a `304` carries the validators a `200` would have and no + content — not even a `Content-Length`, which would describe a body this + reply is forbidden to have. */ + const validators = this.#resourceHeaders(stats); + return { status: 304, headers: validators }; + } + const range = parseRange(head.headers["range"], stats.size); + const headers = this.#resourceHeaders(stats); + if (range.kind === "unsatisfiable") { + throw refuse(416, { headers: { "content-range": `bytes */${stats.size}` } }); + } + const start = range.kind === "range" ? range.start : 0; + const length = range.kind === "range" ? range.length : stats.size; + headers["content-length"] = String(length); + if (range.kind === "range") { + headers["content-range"] = `bytes ${range.start}-${range.end}/${stats.size}`; + } + const status = range.kind === "range" ? 206 : 200; + if (length === 0 || head.method.toUpperCase() === "HEAD") { + /* No body, and — for `HEAD` — no `open` either: the headers above are + already the ones a `GET` would send, and opening here would hand a + descriptor to a reply that is about to drop it. */ + return { status, headers }; + } + /* Opened here rather than inside the generator so that a failure to open is + a status rather than a stream that dies after the status line. The + generator owns the handle and closes it in a `finally`, which runs when a + consumer that has *started* it abandons it — see `server.ts`. */ + const handle = await this.driver.open(path, "r"); + return { status, headers, body: streamHandle(handle, start, length, this.#readChunkBytes) }; + } + + /** + * RFC 9110's four conditional headers, against the representation that is + * there — or against nothing at all. + * + * `200` to carry on, `304` for a `GET`/`HEAD` whose client already has this + * representation, and a thrown `412` for a precondition that failed. The + * rules themselves are `src/http.ts`'s, shared with `mountx/s3`; what is + * here is the case S3's gateway never has to answer, because a `PUT` may name + * a resource that **does not exist yet** and §13.1's answers for that are + * per-header: + * + * - `If-Match` on nothing is `412` (§13.1.1: "if the field value is '*' and + * the resource has no current representation"), and a list of tags cannot + * match a representation that is not there either. This is the header a + * client uses to say "only if you still have the copy I read". + * - `If-None-Match` on nothing **passes** — `If-None-Match: *` is exactly the + * "create only if absent" idiom, and this is where it succeeds. + * - The two date forms are **ignored** on nothing: §13.1.3 and §13.1.4 both + * compare against a last-modified date, and there is none. + * + * @throws {DavFault} `412`. + */ + #conditional(head: WebdavRequestHead, stats: StatsLike | undefined): 200 | 304 { + if (stats === undefined) { + if (head.headers["if-match"] !== undefined) { + throw refuse(412, { message: "If-Match names a representation that is not here" }); + } + return 200; + } + const outcome = evaluateConditionals( + { etag: formatETag(resourceETag(stats)), mtimeMs: stats.mtimeMs }, + head.headers, + head.method.toUpperCase(), + ); + if (outcome.status === 412) { + throw refuse(412, { message: "a conditional header did not match this resource" }); + } + return outcome.status; + } + + /** The headers every resource reply carries, `Content-Length` aside. */ + #resourceHeaders(stats: StatsLike): Record { + return { + "content-type": RESOURCE_CONTENT_TYPE, + "last-modified": formatHttpDate(stats.mtimeMs), + etag: formatETag(resourceETag(stats)), + "accept-ranges": "bytes", + }; + } + + // ------------------------------------------------------------------------- + // PUT + // ------------------------------------------------------------------------- + + /** + * Store a resource (RFC 4918 §9.7). + * + * `201` when it did not exist, `204` when it replaced one. The two refusals + * that are the protocol's rather than the driver's: a `PUT` onto an existing + * collection is `405` (§9.7.2), and a `PUT` whose parent is not a collection + * — missing, or a resource — is `409` (§9.7.1), never the `404` a plain HTTP + * server would answer. + * + * `Content-Range` is `400`: RFC 9110 §14.2 forbids a server from acting on + * one in a `PUT`, and a client that sent it wanted a partial write that this + * would silently turn into a truncating whole-resource one. + */ + async #put( + head: WebdavRequestHead, + path: string, + body: AsyncIterable, + guard: Guard, + ): Promise { + if (head.headers["content-range"] !== undefined) { + throw refuse(400, { message: "Content-Range is not allowed on a PUT (RFC 9110 §14.2)" }); + } + if (path === "/") { + throw refuse(405, { headers: { allow: ALLOW_HEADER } }); + } + const existing = await this.#statOrAbsent(path); + if (existing?.isDirectory() === true) { + throw refuse(405, { headers: { allow: ALLOW_HEADER } }); + } + await this.#requireCollection(dirname(path)); + /* A `PUT` over an existing resource changes that resource; one that creates + a resource also changes its parent's membership (§7.4), and the parent's + own depth-0 lock protects exactly that. */ + this.#requireWritable(path, guard, { membership: existing === undefined }); + /* After the lock check rather than before it: `423` is a fact about the + resource that a client must act on before anything else it might try, + while `412` only says its copy is stale. A `PUT` is never `304` — §13.2.2 + makes that answer `GET`/`HEAD`'s alone — so the outcome here is either + `200` or a thrown `412`. */ + this.#conditional(head, existing); + await this.#write(path, body); + const stats = await this.#statOrAbsent(path); + const headers: Record = { "content-length": "0" }; + if (stats !== undefined) { + headers["etag"] = formatETag(resourceETag(stats)); + headers["last-modified"] = formatHttpDate(stats.mtimeMs); + } + return { status: existing === undefined ? 201 : 204, headers }; + } + + /** + * Stream a body into a resource, and answer how many bytes it held. + * + * The destination is opened at the **first byte**, which is where this + * server's whole write atomicity lives (see the module docs). Each chunk is + * awaited into the driver before the iterator advances, so the transport's + * buffer is free the moment `write` returns and nothing is copied on this + * path. + */ + async #write(path: string, source: AsyncIterable): Promise { + const cap = this.options.maxBodyBytes; + let handle: FileHandleLike | undefined; + let written = 0; + try { + for await (const chunk of source) { + if (chunk.byteLength === 0) { + continue; + } + if (cap !== undefined && written + chunk.byteLength > cap) { + throw refuse(413, { message: `the request body is over the ${cap}-byte budget` }); + } + handle ??= await this.driver.open(path, "w", 0o666); + await handle.write(chunk, 0, chunk.byteLength, written); + written += chunk.byteLength; + } + // An empty resource is still a resource. + handle ??= await this.driver.open(path, "w", 0o666); + } finally { + await handle?.close(); + } + return written; + } + + // ------------------------------------------------------------------------- + // DELETE + // ------------------------------------------------------------------------- + + /** + * Remove a resource or a whole collection (RFC 4918 §9.6). + * + * `Depth` on a collection must be `infinity` — §9.6.1 has no partial delete — + * and the header's absence means `infinity` too. `Depth: 0` on a collection + * is therefore `400`, and on a non-collection every depth is fine because + * there is nothing under it either way. + * + * A delete that fails partway answers `207` naming each resource that would + * not go, which is §9.6.1's own shape: a single status would describe neither + * what was removed nor what is left. A clean delete answers `204` with no + * body — §9.6 is explicit that a `multistatus` must not be sent when + * everything worked. + */ + async #delete(head: WebdavRequestHead, path: string, guard: Guard): Promise { + if (path === "/") { + throw refuse(403, { message: "the root collection is the share itself" }); + } + /* `lstat`, not `stat`, and this is the one place in this file where the + difference is destructive rather than cosmetic: a symbolic link to a + collection would otherwise answer `isDirectory()`, and the recursive + delete below would empty out whatever it points at instead of removing + the one entry the client named. `DELETE` removes the link. */ + const stats = await this.#linkStat(path); + const depth = parseDepth(head.headers["depth"], "infinity"); + if (depth === undefined) { + throw refuse(400, { message: "Depth must be 0, 1 or infinity" }); + } + if (stats.isDirectory() && depth !== "infinity") { + throw refuse(400, { message: "DELETE of a collection is Depth: infinity" }); + } + /* Removing an internal member is a change to the parent collection (§7.4), + so both the resource's own locks and the parent's are in the way. */ + this.#requireWritable(path, guard, { membership: true }); + const locked = this.#lockedMembers(path, guard); + if (locked.length > 0) { + /* A locked member is a failure on a resource other than the request URI, + which §9.6.1 answers with a multistatus — its own example is "a + response with status 423 (Locked) if an internal resource was locked". + Nothing has been deleted at this point: a tree that cannot go whole is + not one to start taking apart. */ + return this.#multistatus(locked); + } + if (!stats.isDirectory()) { + await this.driver.unlink(path); + await this.#discardUnmapped(path, this.#now()); + return { status: 204, headers: { "content-length": "0" } }; + } + const failures = await this.#deleteTree(path); + /* Whatever went, went: the locks rooted on it die with it (§6.1 point 8), + and a partial delete leaves the locks whose roots survived. */ + await this.#discardUnmapped(path, this.#now()); + return failures.length === 0 + ? { status: 204, headers: { "content-length": "0" } } + : this.#multistatus(failures); + } + + /** + * Depth-first removal, collecting what would not go. + * + * A child that fails does **not** stop the walk: the client is owed the whole + * picture, and the parent is then left in place because a directory with + * survivors cannot be removed anyway. "Already gone" is success at every step + * — a concurrent delete of the same tree is not a failure of this one. + * + * The recursion turns on the **dirent**, which describes the entry rather + * than what it points at, so a symbolic link to a collection is unlinked here + * and never walked into. That is the same rule the `lstat` in `#delete` + * applies at the top of the tree. + */ + async #deleteTree(path: string): Promise { + const failures: Failure[] = []; + let entries; + try { + entries = await this.driver.readdir(path, { withFileTypes: true }); + } catch (error) { + if (isAbsent(error)) { + return failures; + } + failures.push({ path, collection: true, status: statusOfError(error) }); + return failures; + } + for (const entry of entries) { + const child = joinPath(path, entry.name); + if (entry.isDirectory()) { + failures.push(...(await this.#deleteTree(child))); + continue; + } + try { + await this.driver.unlink(child); + } catch (error) { + if (!isAbsent(error)) { + failures.push({ path: child, collection: false, status: statusOfError(error) }); + } + } + } + if (failures.length > 0) { + /* Something under it survived, so the collection itself cannot go. Not + reported as its own failure: the client already has the reason, one + level down, and a `409 ENOTEMPTY` on top of it would name a consequence + rather than a cause. */ + return failures; + } + try { + await this.driver.rmdir(path); + } catch (error) { + if (!isAbsent(error)) { + failures.push({ path, collection: true, status: statusOfError(error) }); + } + } + return failures; + } + + // ------------------------------------------------------------------------- + // MKCOL + // ------------------------------------------------------------------------- + + /** + * Create a collection (RFC 4918 §9.3). + * + * The three refusals §9.3.1 names, and they are all answered here rather than + * left to the driver's errno: a body is `415` (this server defines no + * extended `MKCOL`), an existing resource of any kind is `405`, and a missing + * or non-collection parent is `409`. + */ + async #mkcol( + path: string, + body: AsyncIterable, + guard: Guard, + ): Promise { + const content = await collectBody(body, this.#maxXmlBytes); + if (content.byteLength > 0) { + throw refuse(415, { message: "this server defines no MKCOL request body" }); + } + if (path === "/" || (await this.#statOrAbsent(path)) !== undefined) { + throw refuse(405, { headers: { allow: ALLOW_HEADER } }); + } + await this.#requireCollection(dirname(path)); + // A new internal member of the parent collection (§7.4). + this.#requireWritable(path, guard, { membership: true }); + await this.driver.mkdir(path); + return { status: 201, headers: { "content-length": "0" } }; + } + + // ------------------------------------------------------------------------- + // COPY / MOVE + // ------------------------------------------------------------------------- + + /** + * Copy or move a resource (RFC 4918 §9.8, §9.9). + * + * One method with a flag because every rule but the last is shared: the + * `Destination` and `Overwrite` headers, the same-resource refusal, the + * "destination inside the source" refusal, the parent check, and the + * overwrite's own `DELETE`. What differs is the ending — `rename` for a + * `MOVE`, a recursive byte copy for a `COPY` — and the legal depths: §9.9.2 + * gives `MOVE` `infinity` only, while `COPY` also takes `0`, which copies a + * collection without its members. + * + * `201` when the destination was created, `204` when it replaced something, + * which is §9.8.5's table. + */ + async #copyOrMove( + head: WebdavRequestHead, + path: string, + move: boolean, + guard: Guard, + ): Promise { + const destination = parseDestination(head.headers["destination"], head.headers["host"]); + const overwrite = parseOverwrite(head.headers["overwrite"]); + if (overwrite === undefined) { + throw refuse(400, { message: "Overwrite must be T or F" }); + } + const depth = this.#transferDepth(head.headers["depth"], move); + const stats = await this.#stat(path); + if (path === "/") { + /* Checked before the two §9.8.5 refusals below rather than after, even + though the second would catch it — every destination is "inside" the + root — because the reason a client needs is this one: the share's own + root is not a resource this server will move or copy away. */ + throw refuse(403, { message: "the root collection is the share itself" }); + } + if (destination === path) { + /* §9.8.5 and §9.9.5: the source and the destination are the same + resource. `403` rather than a no-op, because a client that asked for + this has a bug the no-op would hide. */ + throw refuse(403, { message: "the destination is the source" }); + } + if (!move && stats.isDirectory() && (await this.#linkStat(path)).isSymbolicLink()) { + /* The same rule `#copyTree` applies to a link it meets *inside* the tree, + applied at the top of it — where it matters more, because the guard + below cannot see through a link. A link to an ancestor makes + `isPathInside(destination, path)` false (the paths are unrelated as + text) while the walk copies a tree it is writing into, creating the + next level as it goes: `COPY /link → /dst` with `/link → /` never + terminates. A `MOVE` is safe and stays allowed — it renames the link + itself and walks nothing. */ + throw refuse(403, { message: "a symbolic link to a collection is not a collection to copy" }); + } + if (stats.isDirectory() && isPathInside(destination, path)) { + /* Copying or moving a collection into itself is the one shape that cannot + terminate: every level copied becomes another level to copy. §9.8.5 and + §9.9.5 both make it a `403`. */ + throw refuse(403, { message: "the destination is inside the source collection" }); + } + await this.#requireCollection(dirname(destination)); + const existing = await this.#statOrAbsent(destination); + /* §7.5.1's example, stated as a rule: "even though both the source and + destination are locked, only one lock token must be submitted (the one + for the lock on the destination) ... because the source resource is not + modified by a COPY". A `MOVE` modifies both ends, so it needs both. */ + if (move) { + this.#requireWritable(path, guard, { membership: true }); + } + this.#requireWritable(destination, guard, { membership: existing === undefined }); + const locked = [ + ...(move ? this.#lockedMembers(path, guard) : []), + ...(existing === undefined ? [] : this.#lockedMembers(destination, guard)), + ]; + if (locked.length > 0) { + // A locked member at either end, named the way §9.6.1 names one. + return this.#multistatus(locked); + } + if (existing !== undefined) { + if (!overwrite) { + throw refuse(412, { message: "the destination exists and Overwrite is F" }); + } + /* §9.8.4: an overwriting COPY or MOVE performs a `DELETE` with + `Depth: infinity` on the destination first, so the result is the source + and nothing of what used to be there. */ + const failures = existing.isDirectory() + ? await this.#deleteTree(destination) + : await this.#unlinkOne(destination); + if (failures.length > 0) { + return this.#multistatus(failures); + } + } + if (move) { + await this.driver.rename(path, destination); + /* §7.6: the lock does not travel with the resource. The source's locks + are unmapped and die (§6.1 point 8); at the destination only a lock + root the move did not recreate does. */ + const at = this.#now(); + await this.#discardUnmapped(path, at); + await this.#discardUnmapped(destination, at); + return { status: existing === undefined ? 201 : 204, headers: { "content-length": "0" } }; + } + const failures = await this.#copyTree(path, destination, stats, depth === "infinity"); + // The source keeps every lock it had (§7.6: "a COPY ... MUST NOT duplicate any write locks"). + await this.#discardUnmapped(destination, this.#now()); + return failures.length > 0 + ? this.#multistatus(failures) + : { status: existing === undefined ? 201 : 204, headers: { "content-length": "0" } }; + } + + /** The legal depths for `COPY` (`0` or `infinity`) and `MOVE` (`infinity`). */ + #transferDepth(value: string | undefined, move: boolean): Depth { + const depth = parseDepth(value, "infinity"); + if (depth === undefined || depth === 1 || (move && depth !== "infinity")) { + throw refuse(400, { + message: move ? "MOVE is Depth: infinity" : "COPY is Depth: 0 or infinity", + }); + } + return depth; + } + + /** + * Copy one resource or one tree, collecting what would not copy. + * + * A collection copied with `Depth: 0` is created empty, which is §9.8.3's + * own wording. Nothing about the source's metadata is carried over: mode, + * ownership and timestamps are the destination's own, because RFC 4918 §9.8.2 + * makes only *dead* properties a copy's business and this server has none — + * and the driver interface has no way to set them at create time anyway. + */ + async #copyTree( + source: string, + destination: string, + stats: StatsLike, + deep: boolean, + ): Promise { + if (!stats.isDirectory()) { + if (!stats.isFile()) { + return [{ path: source, collection: false, status: 403 }]; + } + try { + await this.#copyFile(source, destination, stats.size); + } catch (error) { + return [{ path: source, collection: false, status: statusOfError(error) }]; + } + return []; + } + try { + await this.driver.mkdir(destination); + } catch (error) { + return [{ path: source, collection: true, status: statusOfError(error) }]; + } + if (!deep) { + return []; + } + const failures: Failure[] = []; + const entries = await this.driver.readdir(source, { withFileTypes: true }); + for (const entry of entries) { + const child = joinPath(source, entry.name); + const target = joinPath(destination, entry.name); + const childStats = await this.#statOrAbsent(child); + if (childStats === undefined) { + // It went away between the listing and the copy; there is nothing to copy. + continue; + } + if (entry.isSymbolicLink() && childStats.isDirectory()) { + /* A link to a collection is reported, not walked. Following one is how + a copy fails to terminate: a link back to any ancestor makes the + walk revisit a subtree it is still writing into, and each pass + creates the next one. A link to a *file* is followed — its bytes are + copied — because that cannot recur. */ + failures.push({ path: child, collection: true, status: 403 }); + continue; + } + failures.push(...(await this.#copyTree(child, target, childStats, true))); + } + return failures; + } + + /** Copy one file's bytes, a bounded chunk at a time. */ + async #copyFile(source: string, destination: string, size: number): Promise { + const from = await this.driver.open(source, "r"); + try { + const to = await this.driver.open(destination, "w", 0o666); + try { + let position = 0; + while (position < size) { + const chunk = new Uint8Array(Math.min(this.#readChunkBytes, size - position)); + const { bytesRead } = await from.read(chunk, 0, chunk.byteLength, position); + if (bytesRead <= 0) { + /* The source shrank under the copy — a driver the host also has + open can do that. What was read is what there is. */ + break; + } + await to.write(chunk, 0, bytesRead, position); + position += bytesRead; + } + } finally { + await to.close(); + } + } finally { + await from.close(); + } + } + + /** `unlink` one resource, as the zero-or-one failure list the callers want. */ + async #unlinkOne(path: string): Promise { + try { + await this.driver.unlink(path); + } catch (error) { + if (!isAbsent(error)) { + return [{ path, collection: false, status: statusOfError(error) }]; + } + } + return []; + } + + // ------------------------------------------------------------------------- + // PROPFIND + // ------------------------------------------------------------------------- + + /** + * Read properties (RFC 4918 §9.1). + * + * `Depth` defaults to `infinity` when the header is absent, which §9.1 + * requires — and `infinity` is refused with `403 + * `, which §9.1 explicitly allows a server to do and + * which is the only responsible answer for a driver that may be backed by a + * network store: a client that means "list this collection" sends `Depth: 1`, + * and one that omits the header gets told so in a form it can act on. + * + * A child that vanishes between the listing and its `stat` is left out + * rather than reported: it is not a resource this `PROPFIND` can describe, + * and it was gone before the reply was written. + */ + async #propfind( + head: WebdavRequestHead, + path: string, + body: AsyncIterable, + guard: Guard, + ): Promise { + const depth = parseDepth(head.headers["depth"], "infinity"); + if (depth === undefined) { + throw refuse(400, { message: "Depth must be 0, 1 or infinity" }); + } + if (depth === "infinity") { + throw refuse(403, { condition: "propfind-finite-depth" }); + } + const request = parsePropfind(await collectBody(body, this.#maxXmlBytes)); + const stats = await this.#stat(path); + /* One `now` for the whole document, and it is the request's own: two + resources described by one reply must not report leases read off two + different clocks. */ + const now = guard.now; + const entries: MultistatusEntry[] = [ + { + href: hrefOf(path, stats.isDirectory()), + propstat: await this.#propstats(path, stats, request, now), + }, + ]; + if (depth === 1 && stats.isDirectory()) { + for (const entry of await this.driver.readdir(path, { withFileTypes: true })) { + const child = joinPath(path, entry.name); + const childStats = await this.#statOrAbsent(child); + if (childStats === undefined) { + continue; + } + entries.push({ + href: hrefOf(child, childStats.isDirectory()), + propstat: await this.#propstats(child, childStats, request, now), + }); + } + } + return xmlBody(207, encodeMultistatus(entries)); + } + + /** + * The `propstat` blocks for one resource: what was asked for and found, then + * what was asked for and is not here. + * + * **A `404` block appears only for a request that named names.** `allprop` + * and `propname` ask for whatever the server has, so a property this resource + * does not have — `getcontentlength` on a collection, which §15.4 defines as + * the `Content-Length` of a `GET` that this server answers `405` — is simply + * left out rather than reported missing. Naming it explicitly is a different + * question, with a different answer, and that one gets its `404`. + * + * `propname` answers names with no values, which is what §9.1's third form is + * for; it is built from the same lookup as the values, so it can never + * advertise a property the resource would not then produce. + */ + async #propstats( + path: string, + stats: StatsLike, + request: ReturnType, + now: number, + ): Promise { + const explicit = request.kind === "prop"; + const names = explicit ? request.names : [...ALLPROP_NAMES]; + const found: XmlNode[] = []; + const missing: XmlNode[] = []; + for (const name of names) { + const node = await this.#property(name, path, stats, now); + if (node === undefined) { + if (explicit) { + missing.push({ name }); + } + continue; + } + found.push(request.kind === "propname" ? { name } : node); + } + const propstats: Propstat[] = []; + if (found.length > 0 || missing.length === 0) { + propstats.push({ status: 200, props: found }); + } + if (missing.length > 0) { + propstats.push({ status: 404, props: missing }); + } + return propstats; + } + + /** + * One live property, or `undefined` for one this server does not have. + * + * Everything but the quota pair comes off the `stat` that has already been + * taken. `getcontentlength` and `getetag` are answered for non-collections + * only: RFC 4918 §15.4 defines the first as the `Content-Length` a `GET` + * would carry, and a `GET` of a collection here is `405`. + */ + async #property( + name: string, + path: string, + stats: StatsLike, + now: number, + ): Promise { + const collection = stats.isDirectory(); + switch (name) { + case "creationdate": { + return { name, text: formatIsoDate(stats.birthtimeMs) }; + } + case "displayname": { + /* The root has no name of its own — `basename("/")` answers `"/"`, + which is a path rather than a description — so it displays as + nothing, which is what §15.2 leaves a server to send when there is + nothing to display. */ + return { name, text: path === "/" ? "" : basename(path) }; + } + case "getcontentlength": { + return collection ? undefined : { name, text: String(stats.size) }; + } + case "getcontenttype": { + return { name, text: collection ? COLLECTION_CONTENT_TYPE : RESOURCE_CONTENT_TYPE }; + } + case "getetag": { + return collection ? undefined : { name, text: formatETag(resourceETag(stats)) }; + } + case "getlastmodified": { + return { name, text: formatHttpDate(stats.mtimeMs) }; + } + case "resourcetype": { + return { name, children: collection ? [{ name: "collection" }] : [] }; + } + case "supportedlock": { + /* The same two entries for every resource, collection or not: what a + `LOCK` here accepts does not depend on what it is aimed at (§15.10). */ + return supportedLockNode(); + } + case "lockdiscovery": { + /* Every lock whose scope covers this resource, which includes the + depth-infinity one rooted above it — §15.8 describes "the active + locks on a resource", and an indirectly locked member is locked. + Empty, with the element still sent, when there are none. */ + return lockDiscoveryNode(this.locks.covering(path, now), now); + } + case "quota-available-bytes": + case "quota-used-bytes": { + return await this.#quota(name, path); + } + default: { + return undefined; + } + } + } + + /** + * RFC 4331's quota pair, from `statfs`. + * + * `available` is what this caller could still write (`bavail`, the + * unprivileged figure, not `bfree`); `used` is total minus free. A driver + * without `statfs` answers `ENOSYS` and the property is simply not here — + * a `404` propstat, never a zero, because "no quota information" and "no + * space left" are different answers and a client acts on them differently. + */ + async #quota(name: string, path: string): Promise { + try { + const statfs = await this.driver.statfs(path); + const size = BigInt(Math.trunc(statfs.bsize)); + const value = + name === "quota-available-bytes" + ? BigInt(Math.trunc(statfs.bavail)) * size + : (BigInt(Math.trunc(statfs.blocks)) - BigInt(Math.trunc(statfs.bfree))) * size; + return { name, text: value < 0n ? 0n : value }; + } catch { + /* `ENOSYS` from a driver without `statfs`, and anything else a driver + answers: either way this server has no quota to report, and a `404` + propstat says exactly that. */ + return undefined; + } + } + + // ------------------------------------------------------------------------- + // PROPPATCH + // ------------------------------------------------------------------------- + + /** + * Write properties, in the form §9.2 requires — which here means writing one + * and refusing the rest. + * + * Every property the request named is in the reply with its own status, which + * is why this is a `207` and never a plain `403`: a client that sent one + * property it could set alongside one it could not needs to see which was + * which. What each property gets is {@link WebdavSession.#settable}'s answer. + * + * **Nothing is written until every instruction has an answer.** §9.2 makes the + * method atomic — "instructions MUST either all be executed or none executed" + * — so a request naming `getlastmodified` and one protected property changes + * neither, and §9.2.1 turns the one that could have succeeded into `424 + * Failed Dependency`. Building the outcomes first is what makes that true by + * construction rather than by an undo path there is no way to write. + */ + async #proppatch( + path: string, + body: AsyncIterable, + guard: Guard, + ): Promise { + const request = parseProppatch(await collectBody(body, this.#maxXmlBytes)); + const stats = await this.#stat(path); + // §7's list of what a write lock covers names PROPPATCH explicitly. + this.#requireWritable(path, guard); + /* Document order, which §9.2 makes normative, and one outcome per + instruction before anything is written — because the write only happens + if *every* instruction can succeed. */ + const outcomes: PropertyOutcome[] = [ + ...request.set.map((instruction) => this.#settable(instruction, path, stats)), + ...request.remove.map((name) => ({ + name, + status: 403, + condition: "cannot-modify-protected-property", + })), + ]; + const blocked = outcomes.some((outcome) => outcome.status !== 200); + if (!blocked) { + for (const outcome of outcomes) { + /* v8 ignore next 3 -- every `200` outcome carries an `apply`; the guard + is what keeps that a fact rather than an assumption. */ + if (outcome.apply !== undefined) { + await outcome.apply(); + } + } + } + return xmlBody( + 207, + encodeMultistatus([ + { href: hrefOf(path, stats.isDirectory()), propstat: propstatsOf(outcomes, blocked) }, + ]), + ); + } + + /** + * What one `` instruction can do here: store it, or say why not. + * + * **`getlastmodified` is the one property this server can write**, and it can + * only because the driver interface has a call for it: `utimes`. Everything + * else is either live and derived — `getetag`, `getcontentlength`, + * `resourcetype`, which §15 calls protected outright — or dead, and a dead + * property needs a store the driver interface does not have (see the module + * docs). Both answer `403` with §9.2.1's `cannot-modify-protected-property`. + * + * The two refusals that are not that one: + * + * - **`409`** for a value that is not an `HTTP-date` (§15.7 defines the + * property as one). §9.2.1 defines that status as + * "the client has provided a value whose semantics are not appropriate for + * the property", which is exactly a `getlastmodified` that does not parse. + * - **`403`** again, for a driver that cannot keep it: `times` is a declared- + * or-inferred capability (`AGENTS.md`, invariant 5), and on a driver + * without it `getlastmodified` really *is* protected — it is whatever the + * `stat` says and no request can change that. Answering `200` and dropping + * the value on the floor is the one thing that must not happen. + * + * The `atime` handed to `utimes` is the resource's own, from the `stat` this + * request already took: RFC 4918 has no property for it, so a `PROPPATCH` + * that changed it would be changing something the client never named. + */ + #settable(instruction: ProppatchSet, path: string, stats: StatsLike): PropertyOutcome { + const { name, text } = instruction; + if (name !== "getlastmodified") { + return { name, status: 403, condition: "cannot-modify-protected-property" }; + } + if (!this.driver.capabilities.times) { + return { name, status: 403, condition: "cannot-modify-protected-property" }; + } + const at = parseHttpDate(text.trim()); + if (at === undefined) { + /* No §16 condition: there is none for a bad value, and inventing one + would be a machine-readable claim about a rule that does not exist. */ + return { name, status: 409 }; + } + return { + name, + status: 200, + apply: async () => { + await this.driver.utimes(path, new Date(stats.atimeMs), new Date(at)); + }, + }; + } + + // ------------------------------------------------------------------------- + // the If header, and the locks it unlocks + // ------------------------------------------------------------------------- + + /** + * Evaluate the `If` header and collect what it submitted (RFC 4918 §10.4). + * + * §10.4.1 gives the header two purposes and insists they are separate, and + * this method is where that separation lives: + * + * 1. **A precondition.** Every list is evaluated; if the header has lists and + * none of them is true, the request is `412` and nothing else happens. + * 2. **A submission.** Every state token in it counts as submitted + * "independently of whether or not the condition it expressed was found to + * be true" — so the tokens survive an evaluation that failed, which is what + * §10.4.8's `(Not )` idiom relies on. + * + * @throws {DavFault} `400` for a header that is not the grammar, `412` for + * one that evaluated to false. + */ + async #guard(head: WebdavRequestHead, path: string, evaluate: boolean): Promise { + const now = this.#now(); + const lists = this.#ifLists(head); + if (lists === undefined) { + return { now, submitted: new Set(), lists: undefined }; + } + const guard: Guard = { now, submitted: new Set(submittedTokens(lists)), lists }; + if (evaluate) { + await this.#requireIf(guard, path); + } + return guard; + } + + /** + * The precondition half of §10.4.1, on its own: `412` unless some list is + * true. + * + * Separate from {@link WebdavSession.#guard} because `LOCK` calls it at a + * different moment — see {@link Guard.lists} — and idempotent, since a header + * that is true stays true within one request. + * + * @throws {DavFault} `412`. + */ + async #requireIf(guard: Guard, path: string): Promise { + if (guard.lists !== undefined && !(await this.#evaluateIf(guard.lists, path, guard.now))) { + throw refuse(412, { message: "the If header's state lists all evaluated to false" }); + } + } + + /** + * Is any list true? A list is true when **every** condition in it is + * (§10.4.3: conjunction inside a list, disjunction between them). + * + * The state of each resource is read at most once per request, because a + * header naming the same resource in three lists is one `stat`, not three — + * and because two lists about one resource must not be evaluated against two + * different views of it. + */ + async #evaluateIf(lists: readonly IfList[], path: string, now: number): Promise { + const states = new Map(); + for (const list of lists) { + const state = list.foreign + ? UNKNOWN_RESOURCE + : await this.#resourceState(list.resource ?? path, states, now); + let all = true; + for (const condition of list.conditions) { + if (matchesCondition(condition, state) === condition.negated) { + all = false; + break; + } + } + if (all) { + return true; + } + } + /* v8 ignore next 2 -- `parseIf` never answers an empty list array, so this + loop always ran at least once; the `false` is the honest fallthrough. */ + return false; + } + + /** + * What an `If` condition can be matched against: the tokens on the resource + * and its entity tag. + * + * §10.4.4's "handling unmapped URLs" rule is the reason both halves are + * optional rather than an error: a URL with nothing at it is treated "as if + * the URL identified a resource that exists but does not have the specified + * state", so it has no tokens and no tag, every plain condition against it is + * false, and every negated one is true. A **collection** has no entity tag + * here for the same reason `getetag` is not one of its properties — §15.4 + * defines it against a `GET` this server answers `405` — while its lock + * tokens are as real as any resource's. + */ + async #resourceState( + path: string, + cache: Map, + now: number, + ): Promise { + const cached = cache.get(path); + if (cached !== undefined) { + return cached; + } + const stats = await this.#statOrAbsent(path); + const state: ResourceState = { + tokens: this.locks.covering(path, now).map((lock) => lock.token), + etag: + stats === undefined || stats.isDirectory() ? undefined : formatETag(resourceETag(stats)), + }; + cache.set(path, state); + return state; + } + + /** + * Refuse a request that would change a write-locked resource without the + * token for it (RFC 4918 §7, §7.5). + * + * "Clients MUST submit a lock-token they are authorized to use in any request + * that modifies a write-locked resource ... or the method MUST fail." What + * counts as modifying is §7's list, and it has two shapes, which is what + * `membership` selects: + * + * - **The resource itself** — its bytes or its properties. Every lock + * covering it applies, the depth-infinity one rooted three levels up + * included (§6.1 point 4). + * - **Its parent's membership** — a request that *creates* or *removes* an + * internal member of a collection (§7.4: "DELETE a collection's direct + * internal member ... PUT or MKCOL request that would create a new internal + * member"). A depth-**0** lock on the parent protects that and nothing else, + * which is exactly the case a coverage test on the member alone would miss. + * + * The refusal is §7.5.2's: `423` with `lock-token-submitted` naming the roots + * that stopped it, because "it can be difficult for the client to find out + * which locked resource made the request fail". + * + * @throws {DavFault} `423`. + */ + #requireWritable(path: string, guard: Guard, options: { membership?: boolean } = {}): void { + const blocking = this.locks + .covering(path, guard.now) + .filter((lock) => !guard.submitted.has(lock.token)); + if (options.membership === true && path !== "/") { + for (const lock of this.locks.covering(dirname(path), guard.now)) { + if (!guard.submitted.has(lock.token) && !blocking.includes(lock)) { + blocking.push(lock); + } + } + } + if (blocking.length > 0) { + throw refuse(423, { + condition: "lock-token-submitted", + hrefs: blocking.map((lock) => hrefOf(lock.path, lock.collection)), + message: `${path} is write-locked and no token for it was submitted`, + }); + } + } + + /** + * The members of a tree that are separately locked, as `207` entries. + * + * A lock rooted *below* the request URI is a failure on a **different** + * resource, and §9.6.1 answers those with a multistatus rather than a status: + * "the Multi-Status body could include a response with status 423 (Locked) if + * an internal resource was locked". A lock covering the request URI itself is + * not one of these — that one is {@link WebdavSession.#requireWritable}'s + * plain `423`, which is what §7.5.2's example shows. + */ + #lockedMembers(path: string, guard: Guard): Failure[] { + return this.locks + .within(path, guard.now) + .filter((lock) => lock.path !== path && !guard.submitted.has(lock.token)) + .map((lock) => ({ path: lock.path, collection: lock.collection, status: 423 })); + } + + // ------------------------------------------------------------------------- + // LOCK / UNLOCK + // ------------------------------------------------------------------------- + + /** + * Take a write lock, or refresh one (RFC 4918 §9.10). + * + * The body decides which: one that holds a `lockinfo` creates a lock, and an + * **empty** one refreshes the lock its `If` header names (§7.7 — "a server + * receiving a LOCK request with no body MUST NOT create a new lock"). The + * two share almost nothing, so they are two methods below this one. + * + * `200` for a lock on a resource that was there, `201` for one on a URL that + * was not — §7.3's *locked empty resource*, a real empty file created by this + * request that outlives the lock, because "clients must therefore be + * responsible for cleaning up their own mess". RFC 2518's lock-null resources + * are the alternative §7.3 permits and are deliberately not implemented: a + * resource that is neither there nor absent has no representation in a driver + * that stores files. + */ + async #lock( + head: WebdavRequestHead, + path: string, + body: AsyncIterable, + guard: Guard, + ): Promise { + const info = parseLockInfo(await collectBody(body, this.#maxXmlBytes)); + const now = guard.now; + const timeout = parseTimeout(head.headers["timeout"]); + if (info === undefined) { + return await this.#refreshLock(path, timeout, guard); + } + await this.#requireIf(guard, path); + /* §9.10.3: `0` or `infinity` and nothing else — `1` is a depth the lock + model has no meaning for — and an absent header is `infinity`. */ + const depth = parseDepth(head.headers["depth"], "infinity"); + if (depth === undefined || depth === 1) { + throw refuse(400, { message: "LOCK is Depth: 0 or infinity (RFC 4918 §9.10.3)" }); + } + const existing = await this.#statOrAbsent(path); + /* The conflict is checked before the empty resource is created, so a + refused LOCK on an unmapped URL leaves the namespace as it found it. The + authoritative check is still the one inside `create` — it is the one with + no `await` between the test and the grant. */ + const blocking = this.locks.conflict(path, depth, info.exclusive, now); + if (blocking !== undefined) { + throw this.#conflictingLock(blocking); + } + const collection = existing?.isDirectory() ?? false; + if (existing === undefined) { + /* §9.10.6's `409`: "a resource cannot be created at the destination until + one or more intermediate collections have been created. The server MUST + NOT create those intermediate collections automatically." */ + await this.#requireCollection(dirname(path)); + /* The empty resource §7.3 creates is a new internal member of its parent, + so a lock on that collection has to be submitted for it (§7.4). The + lock being *taken* is judged by the compatibility table alone + (§9.10.5), which the conflict check above is. */ + this.#requireWritable(path, guard, { membership: true }); + await (await this.driver.open(path, "w", 0o666)).close(); + } + const grant = this.locks.create( + { + path, + collection, + depth, + exclusive: info.exclusive, + owner: info.owner, + timeoutSeconds: timeout, + }, + now, + ); + if (grant.kind === "conflict") { + /* Lost a race with another request between the check above and here. The + empty resource a `201` would have created stays, which is §7.3's own + rule that it "SHOULD NOT disappear when its lock goes away". */ + throw this.#conflictingLock(grant.lock); + } + if (grant.kind === "full") { + throw refuse(503, { message: "this share is holding as many locks as it will hold" }); + } + return xmlBody(existing === undefined ? 201 : 200, encodeLockResponse(grant.lock, now), { + "lock-token": formatLockToken(grant.lock.token), + }); + } + + /** + * Restart a lock's lease (RFC 4918 §9.10.2). + * + * The request names the lock in its `If` header and nowhere else — "this + * request MUST NOT have a body and it MUST specify which lock to refresh by + * using the 'If' header with a single lock token" — so a refresh with no `If` + * is a `400`, and one whose token names no lock **whose scope covers this + * URL** is the `412 lock-token-matches-request-uri` §9.10.6 defines for + * exactly that ("the Request-URI did not fall within the scope of the lock + * identified by the token ... or the lock could have disappeared, or the + * token may be invalid" — one status for all three, because the client's next + * move is the same). + * + * `Depth` is ignored, which §9.10.2 requires. There is no `Lock-Token` + * response header: §9.10.2 says it "is not returned in the response for a + * successful refresh", since no token was created. + */ + async #refreshLock( + path: string, + timeout: number | "infinite" | undefined, + guard: Guard, + ): Promise { + const now = guard.now; + if (guard.lists === undefined) { + throw refuse(400, { message: "a LOCK with no body refreshes a lock and needs an If header" }); + } + /* The first submitted token that names a live lock covering this URL. More + than one is a request §9.10.2 does not define ("only one lock may be + refreshed at a time"); refreshing the first is the reading that does + something rather than nothing, and a client that meant the other one gets + a `timeout` element saying which lock it actually refreshed. */ + for (const token of guard.submitted) { + const lock = this.locks.find(token, now); + if (lock !== undefined && DavLockTable.inScope(lock, path)) { + /* The header named a lock that is really here; now it has to be true as + a precondition as well (§10.4.1's two purposes, in the order that + gives each refusal its own reason). */ + await this.#requireIf(guard, path); + const refreshed = this.locks.refresh(token, timeout, now); + /* v8 ignore next 3 -- `find` just answered for this token and nothing + awaits in between, so `refresh` cannot miss it. */ + if (refreshed === undefined) { + break; + } + return xmlBody(200, encodeLockResponse(refreshed, now)); + } + } + throw refuse(412, { condition: "lock-token-matches-request-uri" }); + } + + /** + * Delete a lock (RFC 4918 §9.11). + * + * The token comes from the `Lock-Token` header rather than from `If`, which + * §9.11 notes is inconsistent with every other state-changing method and is + * the protocol's own choice. `204` on success — "rather than 200 OK, since + * 200 OK would imply a response body" — `400` when no token was provided, and + * `409 lock-token-matches-request-uri` when the token names no live lock or + * names one whose scope does not cover this URL. + * + * Unlocking is not itself lock-protected: a client holding the token *is* the + * proof, and requiring the same token twice — once in `Lock-Token` and once + * in `If` — is not something §9.11 asks for. + */ + #unlock(head: WebdavRequestHead, path: string): WebdavResponse { + const token = parseLockToken(head.headers["lock-token"]); + if (token === undefined) { + throw refuse(400, { message: "UNLOCK needs a Lock-Token header holding a Coded-URL" }); + } + const now = this.#now(); + const lock = this.locks.find(token, now); + if (lock === undefined || !DavLockTable.inScope(lock, path)) { + throw refuse(409, { condition: "lock-token-matches-request-uri" }); + } + this.locks.remove(token); + return { status: 204, headers: { "content-length": "0" } }; + } + + /** The `423` a conflicting lock earns, naming the lock that is in the way. */ + #conflictingLock(lock: DavLock): DavFault { + /* §16 on `no-conflicting-lock`: "a lock can be in conflict although the + resource to which the request was directed is only indirectly locked. In + this case, the precondition code can be used to inform the client about + the resource that is the root of the conflicting lock, avoiding a + separate lookup of the lockdiscovery property." */ + return refuse(423, { + condition: "no-conflicting-lock", + hrefs: [hrefOf(lock.path, lock.collection)], + message: `${lock.path} is already locked`, + }); + } + + /** + * The `If` header's lists, or `undefined` when there is no header. + * + * @throws {DavFault} `400` for a header that is not §10.4.2's grammar. + */ + #ifLists(head: WebdavRequestHead): IfList[] | undefined { + const header = head.headers["if"]; + if (header === undefined) { + return undefined; + } + const lists = parseIf(header, head.headers["host"]); + if (lists === undefined) { + throw refuse(400, { message: "the If header is not RFC 4918 §10.4.2's grammar" }); + } + return lists; + } + + /** + * Delete every lock under `path` whose root stopped existing (§6.1 point 8). + * + * "If a request causes the lock-root of any lock to become an unmapped URL, + * then the lock MUST also be deleted by that request" — so a `DELETE` and the + * source side of a `MOVE` destroy the locks they unmap, and a lock never + * follows its resource (§7.6). + * + * The roots are **checked rather than assumed**, with one `stat` per lock + * under the path and none at all in the overwhelmingly common case of no + * locks there. That is what makes the same helper right at both ends of a + * `MOVE`: at the source every root really has gone, while at an overwritten + * destination the root itself was remapped by this very request — §7.6's "if + * there is an existing lock at the destination, the server MUST add the moved + * resource to the destination lock scope" — and only the members that were + * not recreated are unmapped. + */ + async #discardUnmapped(path: string, now: number): Promise { + for (const lock of this.locks.within(path, now)) { + if ((await this.#statOrAbsent(lock.path)) === undefined) { + this.locks.remove(lock.token); + } + } + } + + // ------------------------------------------------------------------------- + // shared driver calls + // ------------------------------------------------------------------------- + + /** + * `stat`, with "nothing there" as a `404`. + * + * `stat` rather than `lstat` throughout: WebDAV has no symbolic link, so a + * link is the resource it points at — the same choice `mountx/s3` makes, and + * the reason a dangling one is a `404` rather than a resource with no body. + */ + async #stat(path: string): Promise { + try { + return await this.driver.stat(path); + } catch (error) { + throw isAbsent(error) ? refuse(404) : error; + } + } + + /** + * `lstat`, with `stat` as the fallback and "nothing there" as a `404`. + * + * `lstat` is optional on `FsDriver`, so a driver without one answers `ENOSYS` + * through the loopback and gets the following `stat` instead — which is + * exactly right, because a driver with no `lstat` has no symbolic links for + * the distinction to matter to. + */ + async #linkStat(path: string): Promise { + try { + return await this.driver.lstat(path); + } catch (error) { + if (errorCode(error) !== "ENOSYS") { + throw isAbsent(error) ? refuse(404) : error; + } + } + return await this.#stat(path); + } + + /** `stat`, with "nothing there" as `undefined`. */ + async #statOrAbsent(path: string): Promise { + try { + return await this.driver.stat(path); + } catch (error) { + if (isAbsent(error)) { + return undefined; + } + throw error; + } + } + + /** + * The parent a write needs: it must exist and it must be a collection. + * + * `409 Conflict` for both, which is §9.7.1's answer and the one place a + * WebDAV server most visibly differs from a plain HTTP one — this server does + * **not** create intermediate collections the way `mountx/s3` conjures a + * prefix, because in WebDAV the client is the one that says `MKCOL`. + */ + async #requireCollection(path: string): Promise { + const stats = await this.#statOrAbsent(path); + if (stats === undefined || !stats.isDirectory()) { + throw refuse(409, { message: `${path} is not a collection` }); + } + } + + /** A `207` naming each resource a recursive operation could not deal with. */ + #multistatus(failures: readonly Failure[]): WebdavResponse { + return xmlBody( + 207, + encodeMultistatus( + failures.map((failure) => ({ + href: hrefOf(failure.path, failure.collection), + status: failure.status, + })), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +/** An error's POSIX `code`, if it has one. */ +function errorCode(error: unknown): string | undefined { + if (typeof error === "object" && error !== null) { + const code = (error as { code?: unknown }).code; + if (typeof code === "string") { + return code; + } + } + return undefined; +} + +/** Does this errno mean "there is nothing at that path"? */ +function isAbsent(error: unknown): boolean { + const code = errorCode(error); + return code === "ENOENT" || code === "ENOTDIR"; +} + +/** + * The `user:password` inside a `Basic` credential, or `undefined` for anything + * that is not one. + * + * The base64 is decoded strictly — node accepts sloppy base64 and would turn a + * malformed header into a wrong-but-plausible string — and the result is + * compared whole, so a password containing a colon works and a username + * containing one cannot exist, which is RFC 7617 §2's own rule. + */ +function parseBasic(header: string): string | undefined { + const match = /^Basic +([A-Za-z0-9+/]+={0,2})$/.exec(header.trim()); + if (match === null) { + return undefined; + } + const decoded = Buffer.from(match[1] as string, "base64"); + if (decoded.toString("base64").replace(/=+$/, "") !== (match[1] as string).replace(/=+$/, "")) { + return undefined; + } + const text = decoded.toString("utf8"); + return text.includes(":") ? text : undefined; +} + +/** + * Are these two strings equal, without telling a timer how far they matched? + * + * Both are hashed first so the comparison is over two 32-byte buffers whatever + * the inputs were: `timingSafeEqual` throws on a length mismatch, and comparing + * the strings directly would leak the credential's length through that throw. + */ +function digestEquals(supplied: string, expected: string): boolean { + const left = createHash("sha256").update(supplied, "utf8").digest(); + const right = createHash("sha256").update(expected, "utf8").digest(); + return timingSafeEqual(left, right); +} + +/** + * Read `length` bytes from `start`, a bounded chunk at a time, and close the + * handle when the consumer is done with it — including the consumer that walks + * away mid-download, whose `return()` runs this `finally`. + */ +async function* streamHandle( + handle: FileHandleLike, + start: number, + length: number, + chunkBytes: number, +): AsyncGenerator { + try { + let position = start; + let remaining = length; + while (remaining > 0) { + const size = Math.min(chunkBytes, remaining); + const buffer = new Uint8Array(size); + const { bytesRead } = await handle.read(buffer, 0, size, position); + if (bytesRead <= 0) { + return; + } + yield bytesRead === size ? buffer : buffer.subarray(0, bytesRead); + position += bytesRead; + remaining -= bytesRead; + } + } finally { + await handle.close(); + } +} diff --git a/test/index.test.ts b/test/index.test.ts index f9f9a22..2f76537 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -50,6 +50,7 @@ describe("every entry point runs under node's type stripping", () => { "src/nfs/index.ts", "src/9p/index.ts", "src/s3/index.ts", + "src/webdav/index.ts", "src/drivers/memory.ts", "src/drivers/node-fs.ts", "src/drivers/unstorage.ts", diff --git a/test/webdav/locks.test.ts b/test/webdav/locks.test.ts new file mode 100644 index 0000000..7bba9e0 --- /dev/null +++ b/test/webdav/locks.test.ts @@ -0,0 +1,269 @@ +/** + * The WebDAV lock table on its own: no driver, no session, no clock. + * + * Every assertion here answers to RFC 4918's lock model (§6, §7) rather than to + * a method's wire shape — the compatibility table of §9.10.5, the two scopes of + * §7.4, the lease of §6.6, and §6.1 point 8's rule that a lock dies with its + * root. `now` is a plain number that moves when the test says so, which is the + * whole reason `DavLockTable` takes it as an argument, and `newToken` is a + * counter so a token is a value a test can write down. + */ + +import { describe, expect, it } from "vitest"; +import { DavLockTable, type DavLock } from "../../src/webdav/locks.ts"; + +/** A table whose tokens count up, so every one of them is distinct and known. */ +function tableOf(options: ConstructorParameters[0] = {}): DavLockTable { + let minted = 0; + return new DavLockTable({ newToken: () => `urn:uuid:token-${++minted}`, ...options }); +} + +/** Take a lock and hand back the record, failing loudly if it was refused. */ +function granted( + table: DavLockTable, + request: Parameters[0], + now: number, +): DavLock { + const grant = table.create(request, now); + if (grant.kind !== "granted") { + throw new Error(`expected a grant, got ${grant.kind}`); + } + return grant.lock; +} + +/* One base time, and every other moment in the file is an offset from it — so a + lease and an expiry are never the same number by accident. */ +const START = 1_700_000_000_000; + +const EXCLUSIVE_TREE = { + path: "/notes", + collection: true, + depth: "infinity", + exclusive: true, + timeoutSeconds: 120, +} as const; + +/* The same tree, shared — so a lock on a member can coexist with it and the + two-lock cases below are about scope rather than about compatibility. */ +const SHARED_TREE = { ...EXCLUSIVE_TREE, exclusive: false } as const; + +const SHARED_FILE = { + path: "/notes/draft.txt", + collection: false, + depth: 0, + exclusive: false, + timeoutSeconds: 45, +} as const; + +// --------------------------------------------------------------------------- +// scope +// --------------------------------------------------------------------------- + +describe("scope", () => { + it("covers the lock root, and a depth-infinity lock covers everything under it", () => { + const table = tableOf(); + const tree = granted(table, EXCLUSIVE_TREE, START); + expect(table.covering("/notes", START).map((lock) => lock.token)).toEqual([tree.token]); + expect(table.covering("/notes/a/b.txt", START).map((lock) => lock.token)).toEqual([tree.token]); + // Not a sibling whose name merely starts the same way. + expect(table.covering("/notesXX", START)).toEqual([]); + expect(table.covering("/", START)).toEqual([]); + }); + + it("keeps a depth-0 lock to its own resource", () => { + const table = tableOf(); + const file = granted(table, { ...SHARED_FILE, path: "/dir" }, START); + expect(table.covering("/dir", START).map((lock) => lock.token)).toEqual([file.token]); + expect(table.covering("/dir/inside.txt", START)).toEqual([]); + }); + + it("looks down the tree for lock roots, which is what an unmapping request needs", () => { + const table = tableOf(); + const tree = granted(table, SHARED_TREE, START); + const file = granted(table, SHARED_FILE, START); + expect(table.within("/notes", START).map((lock) => lock.token)).toEqual([ + tree.token, + file.token, + ]); + expect(table.within("/notes/draft.txt", START).map((lock) => lock.token)).toEqual([file.token]); + expect(table.within("/other", START)).toEqual([]); + }); + + it("puts a member inside a depth-infinity scope and outside a depth-0 one", () => { + const table = tableOf(); + const tree = granted(table, SHARED_TREE, START); + const file = granted(table, SHARED_FILE, START); + expect(DavLockTable.inScope(tree, "/notes")).toBe(true); + expect(DavLockTable.inScope(tree, "/notes/deep/er.txt")).toBe(true); + expect(DavLockTable.inScope(file, "/notes/draft.txt")).toBe(true); + expect(DavLockTable.inScope(file, "/notes")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// §9.10.5's compatibility table +// --------------------------------------------------------------------------- + +describe("conflicts", () => { + it("is §9.10.5's table: only shared over shared is granted", () => { + for (const held of [true, false]) { + for (const wanted of [true, false]) { + const table = tableOf(); + granted(table, { path: "/f.txt", collection: false, depth: 0, exclusive: held }, START); + const grant = table.create( + { path: "/f.txt", collection: false, depth: 0, exclusive: wanted }, + START, + ); + expect(grant.kind, `held exclusive=${held}, wanted exclusive=${wanted}`).toBe( + held || wanted ? "conflict" : "granted", + ); + } + } + }); + + it("conflicts through an ancestor's depth-infinity lock, direct or indirect", () => { + const table = tableOf(); + const tree = granted(table, EXCLUSIVE_TREE, START); + const grant = table.create( + { path: "/notes/deep/file.txt", collection: false, depth: 0, exclusive: false }, + START, + ); + expect(grant.kind === "conflict" && grant.lock.token).toBe(tree.token); + }); + + it("refuses a depth-infinity lock over a subtree that already holds one (§7.4)", () => { + const table = tableOf(); + const inner = granted( + table, + { path: "/notes/deep/file.txt", collection: false, depth: 0, exclusive: true }, + START, + ); + const grant = table.create( + { path: "/notes", collection: true, depth: "infinity", exclusive: false }, + START, + ); + expect(grant.kind === "conflict" && grant.lock.token).toBe(inner.token); + // A depth-0 lock on the same collection does not reach down to it. + expect( + table.create({ path: "/notes", collection: true, depth: 0, exclusive: false }, START).kind, + ).toBe("granted"); + }); + + it("lets two shared locks live on one resource, each with its own token", () => { + const table = tableOf(); + const first = granted(table, SHARED_FILE, START); + const second = granted(table, SHARED_FILE, START); + expect(second.token).not.toBe(first.token); + expect(table.covering(SHARED_FILE.path, START)).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// the lease +// --------------------------------------------------------------------------- + +describe("the lease", () => { + it("grants what was asked for, the default when nothing was, and the cap for Infinite", () => { + const table = tableOf({ defaultTimeoutSeconds: 300, maxTimeoutSeconds: 900 }); + const base = { path: "/a", collection: false, depth: 0, exclusive: false } as const; + expect(granted(table, { ...base, timeoutSeconds: 60 }, START).timeoutSeconds).toBe(60); + expect(granted(table, base, START).timeoutSeconds).toBe(300); + expect(granted(table, { ...base, timeoutSeconds: "infinite" }, START).timeoutSeconds).toBe(900); + expect(granted(table, { ...base, timeoutSeconds: 4_100_000_000 }, START).timeoutSeconds).toBe( + 900, + ); + // A zero-second lock would be granted and gone in the same reply. + expect(granted(table, { ...base, timeoutSeconds: 0 }, START).timeoutSeconds).toBe(1); + }); + + it("reports the seconds remaining, rounded down and never negative", () => { + const table = tableOf(); + const lock = granted(table, { ...SHARED_FILE, timeoutSeconds: 45 }, START); + expect(DavLockTable.remaining(lock, START)).toBe(45); + expect(DavLockTable.remaining(lock, START + 1500)).toBe(43); + expect(DavLockTable.remaining(lock, START + 999_000)).toBe(0); + }); + + it("stops existing once its lease has lapsed, with no timer anywhere", () => { + const table = tableOf(); + const lock = granted(table, { ...SHARED_FILE, timeoutSeconds: 45 }, START); + expect(table.find(lock.token, START + 44_000)?.token).toBe(lock.token); + expect(table.find(lock.token, START + 45_000)).toBeUndefined(); + expect(table.covering(SHARED_FILE.path, START + 45_000)).toEqual([]); + expect(table.size(START + 45_000)).toBe(0); + // And the resource is lockable again, which is what expiry is for. + expect(table.create({ ...SHARED_FILE, exclusive: true }, START + 45_000).kind).toBe("granted"); + }); + + it("restarts the counter on a refresh and keeps everything else (§6.6, §9.10.2)", () => { + const table = tableOf({ defaultTimeoutSeconds: 300 }); + const lock = granted(table, { ...EXCLUSIVE_TREE, timeoutSeconds: 120 }, START); + const refreshed = table.refresh(lock.token, 200, START + 100_000); + expect(refreshed).toMatchObject({ + token: lock.token, + path: lock.path, + depth: "infinity", + exclusive: true, + timeoutSeconds: 200, + }); + expect(refreshed?.expiresAt).toBe(START + 100_000 + 200_000); + // The record is replaced rather than mutated, so an earlier snapshot is intact. + expect(lock.timeoutSeconds).toBe(120); + // A refresh that names no live lock answers nothing at all. + expect(table.refresh("urn:uuid:nobody", 200, START)).toBeUndefined(); + expect(table.refresh(lock.token, undefined, START + 999_000_000)).toBeUndefined(); + }); + + it("takes the server's default on a refresh that asked for nothing", () => { + const table = tableOf({ defaultTimeoutSeconds: 300 }); + const lock = granted(table, { ...SHARED_FILE, timeoutSeconds: 45 }, START); + expect(table.refresh(lock.token, undefined, START)?.timeoutSeconds).toBe(300); + }); +}); + +// --------------------------------------------------------------------------- +// lifecycle +// --------------------------------------------------------------------------- + +describe("lifecycle", () => { + it("deletes one lock by token, and says whether there was one", () => { + const table = tableOf(); + const lock = granted(table, SHARED_FILE, START); + expect(table.remove(lock.token)).toBe(true); + expect(table.remove(lock.token)).toBe(false); + expect(table.all(START)).toEqual([]); + }); + + it("finds the roots an unmapping request has to delete, and not the one above them", () => { + /* §6.1 point 8 is the session's to apply — it deletes the roots that really + went — and this is the query it applies it to. */ + const table = tableOf(); + const tree = granted(table, SHARED_TREE, START); + const file = granted(table, SHARED_FILE, START); + expect(table.within("/notes/draft.txt", START).map((lock) => lock.token)).toEqual([file.token]); + table.remove(file.token); + expect(table.all(START).map((lock) => lock.token)).toEqual([tree.token]); + expect(table.within("/notes", START).map((lock) => lock.token)).toEqual([tree.token]); + }); + + it("refuses a lock past the cap, counting only the live ones", () => { + const table = tableOf({ maxLocks: 2 }); + const base = { collection: false, depth: 0, exclusive: false, timeoutSeconds: 30 } as const; + expect(table.create({ ...base, path: "/one" }, START).kind).toBe("granted"); + expect(table.create({ ...base, path: "/two" }, START).kind).toBe("granted"); + expect(table.create({ ...base, path: "/three" }, START).kind).toBe("full"); + // Past their lease the two are gone, so the table is not full any more. + expect(table.create({ ...base, path: "/three" }, START + 30_000).kind).toBe("granted"); + }); + + it("mints a distinct token for every lock, in the urn:uuid form §6.5 encourages", () => { + const table = new DavLockTable(); + const base = { collection: false, depth: 0, exclusive: false } as const; + const first = granted(table, { ...base, path: "/one" }, START); + const second = granted(table, { ...base, path: "/two" }, START); + expect(first.token).toMatch( + /^urn:uuid:[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/, + ); + expect(second.token).not.toBe(first.token); + }); +}); diff --git a/test/webdav/mount.test.ts b/test/webdav/mount.test.ts new file mode 100644 index 0000000..3dca058 --- /dev/null +++ b/test/webdav/mount.test.ts @@ -0,0 +1,715 @@ +/** + * Tier 2: a real **kernel mount** of a JavaScript driver over WebDAV. + * + * ```sh + * pnpm test:webdav:mount # under sudo; `pnpm test:root` runs it too + * ``` + * + * `test/webdav/oracle.test.ts` already drives a foreign client — but `rclone` + * and `curl` speak the *protocol*, and this transport's claim is bigger than + * that: `src/webdav/index.ts` calls it the unprivileged, zero-native-code path + * to a mountpoint. Nothing tested that. This file does: `mount.davfs` puts the + * Linux kernel's VFS in front of the server and the workload below is ordinary + * syscalls — `mkdir`, `open`, `read`, `write`, `rename`, `unlink`, `rmdir`, + * `stat` — issued by `node:fs` and by `ls` in a separate process, with no + * WebDAV vocabulary anywhere in it. What that catches and the protocol tests + * cannot is everything a client only does when a kernel is asking: a `PROPFIND` + * per `getattr`, a `GET` because a page was faulted rather than because a test + * asked for bytes, a `PUT` on `close(2)`, and every one of them against paths + * the VFS chose the escaping of. + * + * ## Why `davfs2` and not `rclone mount` + * + * Both produce a kernel mount, and `davfs2` is the stronger evidence by two + * facts. It is the configuration `src/webdav/session.ts` names when it writes + * down what class 1 costs — so this file is the check on that sentence — and it + * has no VFS layer of its own translating a general object model onto WebDAV: + * `mount.davfs` *is* a WebDAV client, and what it puts on the wire is what a + * kernel asked it for. It also needs no `rclone` remote and no configuration + * file outside the one written here. + * + * The cost is `sudo`. `mount.davfs` is setuid root, but it refuses an + * unprivileged caller unless the mountpoint is already in `/etc/fstab` and the + * caller is in the `davfs2` group — neither of which a test may arrange — so + * this column is root-only, exactly like `test/9p/mount.test.ts`. That is a + * fact about `davfs2`'s privilege model, not about the server: the share + * itself is served by an ordinary user's process over an ordinary TCP socket. + * + * ## What the `DAV` header buys, measured twice + * + * `mount.davfs` sends `OPTIONS` first and believes the answer, so this suite has + * seen both sides of the class-2 line and neither is a guess: + * + * - Against **class 1** — what this server advertised before locking landed — it + * printed `the server does not support locks` and mounted **read-write + * anyway**, sending not one `LOCK`. macOS's `mount_webdav` is the client that + * insists on class 2; this one never did. The observation is kept because it + * is the evidence behind that distinction, and it is recorded in + * `.agents/environment.md` where a historical fact belongs. + * - Against **class 2**, which is what it reads today, it takes a lock per write + * and releases it. That is not decoration: it means the workload below drives + * `LOCK`, the `If` header and `UNLOCK` from a real kernel client rather than + * from a `fetch` this repository wrote, which is the one thing no other test + * here can claim. `#locks` asserts it, so a regression that silently stopped + * advertising class 2 would fail this file rather than pass it quietly. + * + * Either way `use_locks` stays at its `davfs2` default of on: the point is what + * a real client does when it is not told what to do. + * + * ## `davfs2` is a caching client, and the assertions are shaped around it + * + * Its cache sits between the kernel and the protocol, which cuts both ways: + * + * - A `write` returns once the bytes are in the cache; the `PUT` follows on + * `close(2)`, asynchronously. So every driver-side check goes through + * {@link settle}, a **bounded** poll — measured at 1–3 ms on the host in + * `.agents/environment.md`, given fifteen seconds here. + * - A `read` of a file this mount just wrote is answered from the cache and + * never reaches the server, so it proves nothing about `GET`. The read path + * is therefore tested the other way round, through `mountShare`'s `seed` + * hook: the tree is written to the driver with `node:fs` **before** the mount + * exists, so the only way its bytes can appear on the mountpoint is over the + * wire. + * + * Both directions are checked on the driver's own side with `node:fs` against a + * `node-fs` driver over a `mkdtemp` directory, so "what landed" is a real tree + * and not this repository's opinion of one. + * + * ## Hazards this file respects + * + * **Nothing synchronous against the mountpoint.** The server answers from this + * process's event loop, and `mount.davfs` is a separate process that will not + * answer the kernel until the server has answered it — so a `readFileSync` on + * the mountpoint blocks the only thread that could end the request it is + * waiting for. Same rule as `test/nfs/mount.test.ts` and `test/9p/mount.test.ts`, + * and `spawn(…, { cwd })` inside the mountpoint is out for the same reason. + * + * **Teardown is `umount -i`, deliberately.** The `-i` skips `/sbin/umount.davfs`, + * which unmounts and then polls until the `mount.davfs` daemon leaves the + * process table — a wait that never ends in a container whose pid 1 does not + * reap orphans, and one this suite has no business inheriting. `umount -i` + * issues the same `umount(2)`, returns in single-digit milliseconds, and the + * daemon exits on its own when `/dev/fuse` closes. Every unmount is bounded by + * {@link UNMOUNT_TIMEOUT} and runs in an `afterEach` whatever the test did, and + * `davfs2`'s per-mount cache directory is removed with it — see + * {@link forgetCache}, which is the one leak `umount -i` does leave. + * + * No literal control character appears in this file (`AGENTS.md`, invariant 22). + */ + +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants, readFileSync } from "node:fs"; +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, delimiter, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createNodeFsDriver } from "../../src/drivers/node-fs.ts"; +import { createWebdavServer, type WebdavServer } from "../../src/webdav/server.ts"; + +// --------------------------------------------------------------------------- +// the probe +// --------------------------------------------------------------------------- + +/** + * Where `mount.davfs` lives when it is not on `PATH`. + * + * It is a `sbin` binary, and `sbin` is not on an ordinary user's `PATH` on + * every distribution — so a probe that only walked `PATH` would report "no + * client" on a host that has one, which is the failure mode that makes a + * skipping suite worthless. + */ +const HELPER_PATHS = ["/sbin/mount.davfs", "/usr/sbin/mount.davfs"]; + +/** Where `davfs2` keeps the per-mount caches it does not remove itself. */ +const CACHE_ROOT = "/var/cache/davfs2"; + +/** What {@link davfsClientProbe} found. */ +interface DavfsProbe { + /** Can this host actually mount a WebDAV share? */ + readonly usable: boolean; + /** Why not, when it cannot. Always set when `usable` is `false`. */ + readonly reason?: string; + /** The `mount.davfs` this host has, if it has one. */ + readonly helper?: string; + /** Is this process root? `mount.davfs` needs it — see the module docs. */ + readonly root: boolean; + /** Does the kernel have FUSE, which is what `davfs2` mounts through? */ + readonly kernel: boolean; +} + +/** + * Find an executable, `PATH` first and then a list of absolute fallbacks. + * + * Synchronous, because a `describe.skipIf` needs its answer at collection time + * — the same reason `test/webdav/oracle.test.ts` resolves `rclone` this way. + */ +function findExecutable(name: string, extra: readonly string[] = []): string | undefined { + const onPath = (process.env["PATH"] ?? "") + .split(delimiter) + .map((directory) => join(directory, name)); + for (const candidate of [...onPath, ...extra]) { + try { + accessSync(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Not here, or not executable by us. Keep looking. + } + } + return undefined; +} + +/** + * Can this host mount a WebDAV share, and if not, what is missing? + * + * The same shape and the same contract as `nfsClientProbe()` and + * `p9ClientProbe()`: it never throws, it names one missing thing, and a suite + * gated on it skips rather than reddens. It lives here rather than in + * `src/webdav/` on purpose — `mountx/webdav` is not in `mountx/auto` and never + * mounts anything itself, so there is nothing for the transport to probe. + */ +function davfsClientProbe(): DavfsProbe { + const root = (process.getuid?.() ?? -1) === 0; + if (process.platform !== "linux") { + return { + usable: false, + root, + kernel: false, + reason: `mount.davfs is a Linux client and this host is ${process.platform}.`, + }; + } + let kernel = false; + try { + kernel = + /^nodev\s+fuse$/m.test(readFileSync("/proc/filesystems", "utf8")) && + accessSync("/dev/fuse", fsConstants.F_OK) === undefined; + } catch { + kernel = false; + } + const helper = findExecutable("mount.davfs", HELPER_PATHS); + if (helper === undefined) { + return { + usable: false, + root, + kernel, + reason: `no mount.davfs on this host; install the davfs2 package to run this column.`, + }; + } + if (!kernel) { + return { + usable: false, + root, + kernel, + reason: `davfs2 mounts through FUSE, and this kernel has no fuse filesystem or no /dev/fuse.`, + }; + } + if (!root) { + return { + usable: false, + root, + kernel, + helper, + reason: + `mount.davfs refuses an unprivileged caller unless the mountpoint is in /etc/fstab ` + + `and the caller is in the davfs2 group, so this column needs root.`, + }; + } + return { usable: true, root, kernel, helper }; +} + +const probe = davfsClientProbe(); + +// --------------------------------------------------------------------------- +// fixtures +// --------------------------------------------------------------------------- + +/** Longest any one spawn may take, in milliseconds. */ +const SPAWN_TIMEOUT = 30_000; +/** Longest an unmount may take before it is treated as wedged. */ +const UNMOUNT_TIMEOUT = 20_000; +/** Longest {@link settle} waits for a `PUT` to reach the driver. */ +const SETTLE_TIMEOUT = 15_000; +/** How often {@link settle} looks again. */ +const SETTLE_INTERVAL = 25; + +const USERNAME = "ada"; +const PASSWORD = "a pass:word"; + +/** + * The `davfs2` configuration every mount here is given, as a file. + * + * Passed with `-o conf=`, so `/etc/davfs2/davfs2.conf` is neither read for + * these mounts nor written to — a developer's own settings are left alone, and + * the run is the same as CI's. Four settings, each for a reason: + * + * - `ask_auth` — whether a missing password is prompted for. `0` for the + * unauthenticated shares, so a mount can never sit waiting on a terminal. + * - `delay_upload 0` — upload as soon as the file is closed instead of sitting + * on it. It does not make the `PUT` synchronous (hence {@link settle}); it + * stops the wait being the default ten seconds. + * - `dir_refresh` / `file_refresh` — how long a listing and a `stat` are + * trusted, in seconds. One, so a driver-side change is visible within a + * {@link settle} rather than within a minute. + * + * **`use_locks` is deliberately absent**, left at the `davfs2` default of on, so + * what the client does about locking is the client's decision and not this + * file's instruction. It read `DAV: 1, 3` and wrote without locks; it reads + * `DAV: 1, 2, 3` and takes one per write. Both were measured — see the module + * docs — and the second is asserted. + */ +function davfsConfig(askAuth: boolean): string { + return [ + `ask_auth ${askAuth ? 1 : 0}`, + `delay_upload 0`, + `dir_refresh 1`, + `file_refresh 1`, + ``, + ].join("\n"); +} + +/** Deterministic bytes that are not all the same, so a truncation cannot pass. */ +function seeded(size: number): Buffer { + const bytes = Buffer.alloc(size); + for (let index = 0; index < size; index++) { + bytes[index] = (index * 31 + (index >> 11)) & 0xff; + } + return bytes; +} + +/** What `run` reports: the exit status and both streams, interleaved. */ +interface Ran { + readonly status: number | null; + readonly out: string; +} + +/** + * Run a command to completion, bounded, with no `cwd` inside the mountpoint. + * + * The bound is the point: `mount.davfs` against a server that never answers + * would otherwise hang a suite rather than fail it. A child still running at + * the deadline is killed and its exit reported as whatever the signal made it. + */ +function run( + command: string, + args: readonly string[], + options: { stdin?: string; timeout?: number } = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], + timeout: options.timeout ?? SPAWN_TIMEOUT, + killSignal: "SIGKILL", + }); + let out = ""; + for (const stream of [child.stdout, child.stderr]) { + stream?.setEncoding("utf8"); + stream?.on("data", (chunk: string) => { + out += chunk; + }); + } + if (options.stdin !== undefined) { + child.stdin?.end(options.stdin); + } + child.once("error", reject); + child.once("close", (status) => resolve({ status, out })); + }); +} + +/** + * Wait, bounded, for something to become true on the driver's side. + * + * Answers whether it did rather than throwing, so a caller can assert on the + * value and get vitest's own diff instead of a timeout with no detail. See the + * module docs on why a driver-side check needs one of these at all. + */ +async function settle(check: () => Promise, timeout = SETTLE_TIMEOUT): Promise { + const deadline = Date.now() + timeout; + for (;;) { + if (await check().catch(() => false)) { + return true; + } + if (Date.now() >= deadline) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, SETTLE_INTERVAL)); + } +} + +/** Every mount table entry, as `(source, target, type)`. */ +function mountTable(): { source: string; target: string; type: string }[] { + return readFileSync("/proc/self/mounts", "utf8") + .split("\n") + .filter((line) => line !== "") + .map((line) => { + const [source = "", target = "", type = ""] = line.split(" "); + /* `/proc/self/mounts` escapes a space in a path as `\040`; the mountpoints + here are `mkdtemp` names with none, but reading the field correctly + costs one replace and makes the assertion mean what it says. */ + return { source, target: target.replaceAll("\\040", " "), type }; + }); +} + +/** What is mounted at `target`, or `undefined` when nothing is. */ +function mountedAt(target: string): { source: string; type: string } | undefined { + return mountTable().findLast((entry) => entry.target === target); +} + +/** + * Remove the cache `davfs2` keeps for a mountpoint that is gone. + * + * `umount.davfs` would have done this; `umount -i` does not run it (see the + * module docs), so it is done here rather than left to accumulate a copy of + * every byte the suite ever wrote. The directory is named + * `++`, so the + * `mkdtemp` basename identifies ours exactly and nobody else's. Best-effort in + * every direction: a host that keeps its caches somewhere else, or does not let + * us read this, simply has nothing to clean. + */ +async function forgetCache(mountpoint: string): Promise { + const mine = basename(mountpoint); + const entries = await fs.readdir(CACHE_ROOT).catch(() => [] as string[]); + for (const entry of entries.filter((name) => name.includes(mine))) { + await fs.rm(join(CACHE_ROOT, entry), { recursive: true, force: true }).catch(() => {}); + } +} + +/** One mounted share: the kernel's side, the driver's side, and the server between. */ +interface Share { + /** The mountpoint, where the kernel serves it. */ + readonly at: string; + /** The `node-fs` driver's root: what the driver holds, on a real disk. */ + readonly root: string; + /** The server in this process, answering `mount.davfs`. */ + readonly server: WebdavServer; + /** Unmount, bounded. Idempotent, and safe on a share that never mounted. */ + unmount(): Promise; +} + +// --------------------------------------------------------------------------- +// the suite +// --------------------------------------------------------------------------- + +describe.skipIf(!probe.usable)("a real WebDAV mount", () => { + const shares: Share[] = []; + const scratch: string[] = []; + + /** A temp directory this file made, removed at the end whatever happened. */ + async function scratchDir(prefix: string): Promise { + const path = await fs.mkdtemp(join(tmpdir(), prefix)); + scratch.push(path); + return path; + } + + /** + * Serve a fresh `node-fs` driver and mount it, with the driver's tree seeded + * *before* the mount exists. + * + * The seeding order is what makes the read path meaningful — see the module + * docs — and a fresh mountpoint per mount is what makes `davfs2`'s cache + * fresh, since the cache is keyed by the mountpoint's path. + */ + async function mountShare( + options: { + credentials?: { username: string; password: string }; + /** Typed on the helper's stdin, for a share that asks for a password. */ + stdin?: string; + seed?: (root: string) => Promise; + } = {}, + ): Promise<{ share: Share; mount: Ran }> { + const root = await scratchDir("mountx-webdav-root-"); + const at = await scratchDir("mountx-webdav-mnt-"); + const config = join(await scratchDir("mountx-webdav-conf-"), "davfs2.conf"); + await fs.writeFile(config, davfsConfig(options.stdin !== undefined)); + await options.seed?.(root); + + const server = createWebdavServer(createNodeFsDriver(root), { + credentials: options.credentials, + }); + await server.listen(); + + let unmounted: Promise | undefined; + const share: Share = { + at, + root, + server, + unmount: () => { + unmounted ??= (async () => { + if (mountedAt(at) !== undefined) { + await run("umount", ["-i", at], { timeout: UNMOUNT_TIMEOUT }); + } + await forgetCache(at); + })(); + return unmounted; + }, + }; + shares.push(share); + + const credentialOptions = + options.credentials === undefined ? `` : `,username=${options.credentials.username}`; + const mount = await run( + probe.helper as string, + [ + `${server.url}/`, + at, + "-o", + `conf=${config},rw,uid=${process.getuid?.() ?? 0},gid=${process.getgid?.() ?? 0}` + + credentialOptions, + ], + { stdin: options.stdin }, + ); + return { share, mount }; + } + + /** {@link mountShare}, asserting that the mount actually happened. */ + async function mounted( + options: Parameters[0] = {}, + ): Promise string }> { + const { share, mount } = await mountShare(options); + expect(mount.status, `mount.davfs failed: ${mount.out}`).toBe(0); + return { ...share, path: (name: string) => join(share.at, name) }; + } + + /** + * Teardown, and it runs whether the test passed, failed or threw. + * + * Unmount first and remove afterwards, in that order: a `rm -rf` over a live + * mountpoint would walk into the share and delete the driver's contents + * through the kernel, which is a slow way to lose the evidence. + */ + afterEach(async () => { + for (const share of shares.splice(0)) { + await share.unmount().catch(() => {}); + await share.server.close().catch(() => {}); + } + for (const path of scratch.splice(0)) { + await fs.rm(path, { recursive: true, force: true }).catch(() => {}); + } + }); + + it("mounts, appears in the mount table, and unmounts clean", async () => { + const share = await mounted(); + const entry = mountedAt(share.at); + /* `davfs2` mounts through FUSE and names the share as its source, so the + line proves both halves: a real kernel filesystem, fed by this server. */ + expect(entry?.type).toBe("fuse"); + expect(entry?.source).toBe(`${share.server.url}/`); + + await share.unmount(); + expect(mountedAt(share.at)).toBeUndefined(); + // The mountpoint is a plain empty directory again, not a stale entry. + expect(await fs.readdir(share.at)).toEqual([]); + // Idempotent: a second unmount is a no-op rather than an error. + await share.unmount(); + expect(mountedAt(share.at)).toBeUndefined(); + }, 60_000); + + it("carries an ordinary filesystem workload onto the driver", async () => { + const share = await mounted(); + const { path, root } = share; + + // --- a tree, made one syscall at a time --- + await fs.mkdir(path("dir")); + await fs.mkdir(path("dir/nested")); + expect((await fs.stat(path("dir/nested"))).isDirectory()).toBe(true); + expect((await fs.stat(join(root, "dir/nested"))).isDirectory()).toBe(true); + + // --- bytes, seeded so a truncation cannot pass --- + const small = seeded(4096); + await fs.writeFile(path("dir/nested/file.bin"), small); + expect((await fs.readFile(path("dir/nested/file.bin"))).equals(small)).toBe(true); + expect( + await settle(async () => + (await fs.readFile(join(root, "dir/nested/file.bin"))).equals(small), + ), + "the PUT never reached the driver", + ).toBe(true); + + // --- big enough to span several reads and writes --- + const big = seeded(1024 * 1024); + await fs.writeFile(path("big.bin"), big); + expect( + await settle(async () => (await fs.readFile(join(root, "big.bin"))).equals(big)), + "the large PUT never reached the driver intact", + ).toBe(true); + + // --- names the escaping has to survive --- + const spaced = "a name with spaces.txt"; + const unicode = "naïve-¥.txt"; + await fs.writeFile(path(spaced), "spaced"); + await fs.writeFile(path(unicode), "unicode"); + expect( + await settle(async () => (await fs.readFile(join(root, spaced), "utf8")) === "spaced"), + ).toBe(true); + expect( + await settle(async () => (await fs.readFile(join(root, unicode), "utf8")) === "unicode"), + ).toBe(true); + + // --- sizes and mtimes, on both sides --- + const throughMount = await fs.stat(path("big.bin")); + const onDriver = await fs.stat(join(root, "big.bin")); + expect(throughMount.size).toBe(big.byteLength); + expect(onDriver.size).toBe(big.byteLength); + /* `getlastmodified` is an HTTP date, so the mount's view of an mtime is the + driver's rounded down to the second; a second of slack on top of that for + the round trip itself. */ + expect(Math.abs(throughMount.mtimeMs - onDriver.mtimeMs)).toBeLessThan(2000); + + // --- rename --- + await fs.rename(path("dir/nested/file.bin"), path("dir/renamed.bin")); + await expect(fs.stat(path("dir/nested/file.bin"))).rejects.toMatchObject({ code: "ENOENT" }); + expect( + await settle(async () => (await fs.readFile(join(root, "dir/renamed.bin"))).equals(small)), + "the MOVE never reached the driver", + ).toBe(true); + await expect(fs.stat(join(root, "dir/nested/file.bin"))).rejects.toMatchObject({ + code: "ENOENT", + }); + + // --- the errors a kernel expects for the shapes it refuses --- + await expect(fs.stat(path("nope"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.readdir(path("big.bin"))).rejects.toMatchObject({ code: "ENOTDIR" }); + await expect(fs.rmdir(path("dir"))).rejects.toMatchObject({ code: "ENOTEMPTY" }); + await expect(fs.mkdir(path("dir"))).rejects.toMatchObject({ code: "EEXIST" }); + + // --- unlink and rmdir, down to an empty share --- + await fs.unlink(path("dir/renamed.bin")); + await fs.rmdir(path("dir/nested")); + await fs.rmdir(path("dir")); + expect( + await settle(async () => (await fs.readdir(root)).includes("dir") === false), + "the DELETE never reached the driver", + ).toBe(true); + for (const name of ["big.bin", spaced, unicode]) { + await fs.unlink(path(name)); + } + expect(await settle(async () => (await fs.readdir(root)).length === 0)).toBe(true); + }, 120_000); + + it("takes and releases a lock, because the server advertises class 2", async () => { + /* The one thing no other test in this package can claim: `LOCK`, the `If` + header that carries its token, and `UNLOCK`, driven by a real kernel + client that decided on its own to send them — `use_locks` is left at its + default, so the only reason davfs2 locks here is the `DAV: 1, 2, 3` it + read from `OPTIONS`. Asserted rather than described, so that a server + which quietly stopped advertising class 2 fails this file: davfs2 would + go back to writing without a lock, which is what it did before locking + landed (see the module docs). */ + const share = await mounted(); + await fs.writeFile(share.path("locked.txt"), "written through the kernel"); + expect( + await settle(async () => + (await fs.readFile(join(share.root, "locked.txt"), "utf8")).startsWith("written"), + ), + "the write to reach the driver", + ).toBe(true); + + const methods = share.server.session.stats.methods; + expect(methods.get("LOCK") ?? 0).toBeGreaterThan(0); + expect(methods.get("UNLOCK") ?? 0).toBe(methods.get("LOCK") ?? 0); + /* Every one of them succeeded: a `423` would mean the client's own token + was not accepted back, which is the failure this pairing exists to + catch. */ + expect(share.server.session.stats.errors).toBe(0); + expect(share.server.session.assertions).toEqual([]); + }); + + it("reads back what the driver already held, byte for byte", async () => { + /* The other direction, and the one `davfs2`'s cache cannot fake: every one + of these files existed on the driver before the mount did, so a byte on + the mountpoint arrived over the wire. */ + const spaced = "a name with spaces.bin"; + const unicode = "yen-¥.bin"; + const blob = seeded(1024 * 1024); + const share = await mounted({ + seed: async (root) => { + await fs.mkdir(join(root, "held/deeper"), { recursive: true }); + await fs.writeFile(join(root, "held/deeper", spaced), blob); + await fs.writeFile(join(root, "held", unicode), "held on the driver"); + for (let index = 0; index < 40; index++) { + await fs.writeFile(join(root, "held", `f${String(index).padStart(3, "0")}`), "x"); + } + }, + }); + const { path } = share; + + expect((await fs.readFile(path(join("held/deeper", spaced)))).equals(blob)).toBe(true); + expect(await fs.readFile(path(join("held", unicode)), "utf8")).toBe("held on the driver"); + expect((await fs.stat(path(join("held/deeper", spaced)))).size).toBe(blob.byteLength); + + // A listing big enough that it is not one lucky entry, through `readdir`… + const entries = await fs.readdir(path("held"), { withFileTypes: true }); + expect(entries.filter((entry) => entry.isFile())).toHaveLength(41); + expect(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)).toEqual([ + "deeper", + ]); + // …and through a separate process, which is what a real client looks like. + const listed = await run("ls", ["-1", path("held")]); + expect(listed.status).toBe(0); + expect(listed.out.trim().split("\n")).toHaveLength(42); + + // Reading a partial range rather than the whole resource. + const handle = await fs.open(path(join("held/deeper", spaced)), "r"); + try { + const buffer = Buffer.alloc(64); + await handle.read(buffer, 0, 64, 512 * 1024); + expect(buffer.equals(blob.subarray(512 * 1024, 512 * 1024 + 64))).toBe(true); + } finally { + await handle.close(); + } + }, 120_000); + + it("mounts a share behind HTTP Basic, and refuses the wrong password", async () => { + const credentials = { username: USERNAME, password: PASSWORD }; + const share = await mounted({ + credentials, + stdin: `${PASSWORD}\n`, + seed: (root) => fs.writeFile(join(root, "secret.txt"), "only for ada"), + }); + expect(await fs.readFile(share.path("secret.txt"), "utf8")).toBe("only for ada"); + + /* And the refusal is the server's, not the client's guess: `mount.davfs` + only learns it by being handed a 401 with a Basic challenge on it. */ + const wrong = await mountShare({ credentials, stdin: `not the password\n` }); + expect(wrong.mount.status).not.toBe(0); + expect(wrong.mount.out).toContain("Basic"); + expect(mountedAt(wrong.share.at)).toBeUndefined(); + }, 120_000); + + it("unmounts cleanly even after the server has gone", async () => { + /* The worst outcome this suite could have is a mount that outlives the run, + so the case that would cause one is a case. Unlike a wedged FUSE mount, + nothing here needs `umount -f` or an abort through `fusectl`: `davfs2` + fails the request rather than parking on it, and a plain `umount -i` + still returns. */ + const share = await mounted({ + seed: (root) => fs.writeFile(join(root, "before.txt"), "written while the server lived"), + }); + expect(await fs.readFile(share.path("before.txt"), "utf8")).toBe( + "written while the server lived", + ); + + await share.server.close(); + // A read the cache cannot answer now fails; what matters is that it *fails*. + await expect(fs.readFile(share.path("gone.txt"))).rejects.toThrow(); + + await share.unmount(); + expect(mountedAt(share.at)).toBeUndefined(); + expect(await fs.readdir(share.at)).toEqual([]); + }, 120_000); +}); + +// --------------------------------------------------------------------------- +// the other side of the gate +// --------------------------------------------------------------------------- + +describe.skipIf(probe.usable)("without a WebDAV mount client", () => { + it("says what is missing rather than skipping silently", () => { + expect(probe.reason).toBeTruthy(); + expect(probe.usable).toBe(false); + }); + + it("still probes without throwing", () => { + expect(typeof probe.usable).toBe("boolean"); + expect(typeof probe.root).toBe("boolean"); + expect(typeof probe.kernel).toBe("boolean"); + }); +}); diff --git a/test/webdav/oracle.test.ts b/test/webdav/oracle.test.ts new file mode 100644 index 0000000..7995e9a --- /dev/null +++ b/test/webdav/oracle.test.ts @@ -0,0 +1,411 @@ +/** + * Tier 2 for the WebDAV server: a **foreign client**, driven for real. + * + * ```sh + * pnpm test # this file runs when rclone is on PATH, skips when it is not + * ``` + * + * Everything else under `test/webdav/` drives the server with `fetch` and reads + * the replies with this repository's own expectations, so a symmetric + * misreading of RFC 4918 is invisible to it. This file is the answer to that: + * [rclone](https://rclone.org)'s WebDAV backend shares none of our code and has + * opinions about the protocol that no fixture would have thought to have — it + * insists on `Depth: 1` `PROPFIND`s to list, on `MKCOL` before a nested upload, + * and on the `href`s in a multistatus matching the collection it asked about. + * `curl` plays the smaller role of sending each method exactly as written, with + * no backend in between. + * + * It skips itself cleanly when the binaries are absent, the way + * `test/s3/oracle.test.ts` and `test/nfs/mount.test.ts` do: an oracle nobody can + * run is not a reason for a red suite. + * + * ## The configuration is in the environment, and that is deliberate + * + * rclone is invoked with **no config file at all** (`RCLONE_CONFIG=""`) and the + * remote defined entirely by `RCLONE_CONFIG_MX_*` variables, so a run writes + * nothing to `~/.config/rclone` and reads nothing from it — a developer with + * their own remotes configured gets the same run as CI. + * + * `vendor=other` is not a limitation, just the truth: this is not ownCloud and + * not Nextcloud, and claiming to be one turns on checksum and chunked-upload + * endpoints that do not exist here. + * + * ## Shape + * + * One server for the whole file and a collection per test — WebDAV is stateless + * between requests, and a listener per case would spend more time on sockets + * than on rclone. The driver underneath is `node-fs` over a `mkdtemp` + * directory, so "what the driver holds" is a real tree that can be read back + * with `node:fs` and compared byte for byte, which is the second half of every + * assertion here. + * + * Every spawn is bounded by {@link SPAWN_TIMEOUT} and by rclone's own + * `--retries 1 --low-level-retries 1 --timeout`, so a server bug becomes a + * failed assertion in a second rather than a suite that hangs. No literal + * control character appears in this file. + */ + +import { execFile } from "node:child_process"; +import { accessSync, constants as fsConstants } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createNodeFsDriver } from "../../src/drivers/node-fs.ts"; +import { createWebdavServer, type WebdavServer } from "../../src/webdav/server.ts"; + +const run = promisify(execFile); + +// --------------------------------------------------------------------------- +// the probe +// --------------------------------------------------------------------------- + +/** + * Find an executable on `PATH`, the way `command -v` does. Synchronous, because + * a `describe.skipIf` needs its answer at collection time. + */ +function findExecutable( + name: string, + path: string | undefined = process.env["PATH"], +): string | undefined { + const candidates = name.includes("/") + ? [name] + : (path ?? "").split(delimiter).map((directory) => join(directory, name)); + for (const candidate of candidates) { + try { + accessSync(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Not here, or not executable by us. Keep looking. + } + } + return undefined; +} + +const rclone = findExecutable("rclone"); +const curl = findExecutable("curl"); + +// --------------------------------------------------------------------------- +// fixtures +// --------------------------------------------------------------------------- + +/** Longest any one spawn may take, in milliseconds. */ +const SPAWN_TIMEOUT = 30_000; +/** Longest one case may take. Generous, because what it bounds is a *hang*. */ +const CASE_TIMEOUT = 20_000; +const USERNAME = "ada"; +const PASSWORD = "a pass:word"; + +/** The temp directories this file made, removed at the end whatever happened. */ +const scratch: string[] = []; + +let server: WebdavServer; +/** The `node-fs` driver's root: what the driver actually holds, on disk. */ +let root: string; +/** The `RCLONE_CONFIG_MX_*` environment that defines the remote. */ +let rcloneEnv: NodeJS.ProcessEnv; + +async function scratchDir(prefix: string): Promise { + const path = await mkdtemp(join(tmpdir(), prefix)); + scratch.push(path); + return path; +} + +/** Deterministic bytes that are not all the same, so a truncation cannot pass. */ +function seeded(size: number): Buffer { + const bytes = Buffer.alloc(size); + for (let index = 0; index < size; index++) { + bytes[index] = (index * 31 + (index >> 11)) & 0xff; + } + return bytes; +} + +async function rcloneRun(...args: string[]): Promise<{ stdout: string; stderr: string }> { + return await run( + rclone as string, + ["--retries", "1", "--low-level-retries", "1", "--timeout", "10s", ...args], + { env: rcloneEnv, timeout: SPAWN_TIMEOUT, maxBuffer: 32 * 1024 * 1024 }, + ); +} + +async function curlRun(...args: string[]): Promise { + const { stdout } = await run( + curl as string, + ["--silent", "--show-error", "--max-time", "10", "--user", `${USERNAME}:${PASSWORD}`, ...args], + { timeout: SPAWN_TIMEOUT, maxBuffer: 8 * 1024 * 1024 }, + ); + return stdout; +} + +beforeAll(async () => { + root = await scratchDir("mountx-webdav-oracle-"); + server = createWebdavServer(createNodeFsDriver(root), { + credentials: { username: USERNAME, password: PASSWORD }, + }); + await server.listen(); + /* The server is all curl needs, so it is built whatever is on PATH. The + remote below is not: `obscure` is rclone's own password encoding, and only + rclone can produce it. Without this guard a host with curl and no rclone + fails the whole file in setup instead of skipping the half it cannot run. */ + if (rclone === undefined) return; + rcloneEnv = { + ...process.env, + RCLONE_CONFIG: "", + RCLONE_CONFIG_MX_TYPE: "webdav", + RCLONE_CONFIG_MX_URL: server.url, + RCLONE_CONFIG_MX_VENDOR: "other", + RCLONE_CONFIG_MX_USER: USERNAME, + RCLONE_CONFIG_MX_PASS: ( + await run(rclone as string, ["obscure", PASSWORD], { timeout: SPAWN_TIMEOUT }) + ).stdout.trim(), + }; +}); + +afterAll(async () => { + await server?.close(); + for (const path of scratch.splice(0)) { + await rm(path, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// rclone +// --------------------------------------------------------------------------- + +describe.skipIf(rclone === undefined)("rclone against mountx/webdav", () => { + it( + "syncs a tree in, lists it, and syncs it back out byte for byte", + async () => { + const source = await scratchDir("mountx-webdav-source-"); + await mkdir(join(source, "nested"), { recursive: true }); + await writeFile(join(source, "hello.txt"), "hello world"); + await writeFile(join(source, "nested", "a name with spaces.bin"), seeded(64 * 1024)); + + await rcloneRun("sync", source, "mx:tree", "--create-empty-src-dirs"); + + // What the driver holds is a real tree, so it is checked on disk too. + expect(await readFile(join(root, "tree", "hello.txt"), "utf8")).toBe("hello world"); + expect(await readFile(join(root, "tree", "nested", "a name with spaces.bin"))).toEqual( + seeded(64 * 1024), + ); + + const listing = JSON.parse((await rcloneRun("lsjson", "-R", "mx:tree")).stdout) as { + Path: string; + IsDir: boolean; + Size: number; + }[]; + expect(listing.map((entry) => entry.Path).sort()).toEqual([ + "hello.txt", + "nested", + "nested/a name with spaces.bin", + ]); + expect(listing.find((entry) => entry.Path === "nested")?.IsDir).toBe(true); + expect(listing.find((entry) => entry.Path === "hello.txt")?.Size).toBe(11); + + const back = await scratchDir("mountx-webdav-back-"); + await rcloneRun("sync", "mx:tree", back); + expect(await readFile(join(back, "nested", "a name with spaces.bin"))).toEqual( + seeded(64 * 1024), + ); + }, + CASE_TIMEOUT, + ); + + it( + "moves, copies and purges through the server's own methods", + async () => { + await writeFile(join(root, "movable.txt"), "before"); + await rcloneRun("moveto", "mx:movable.txt", "mx:moved.txt"); + expect(await readFile(join(root, "moved.txt"), "utf8")).toBe("before"); + await expect(stat(join(root, "movable.txt"))).rejects.toThrow(); + + await rcloneRun("copyto", "mx:moved.txt", "mx:copies/again.txt"); + expect(await readFile(join(root, "copies", "again.txt"), "utf8")).toBe("before"); + + await rcloneRun("purge", "mx:copies"); + await expect(stat(join(root, "copies"))).rejects.toThrow(); + }, + CASE_TIMEOUT, + ); + + it( + "reads a byte range rather than the whole resource", + async () => { + await writeFile(join(root, "ranged.bin"), seeded(256 * 1024)); + const { stdout } = await rcloneRun( + "cat", + "--offset", + "1024", + "--count", + "16", + "mx:ranged.bin", + ); + expect(Buffer.from(stdout, "binary").byteLength).toBe(16); + }, + CASE_TIMEOUT, + ); + + it( + "refuses the wrong password rather than serving it", + async () => { + await expect( + run(rclone as string, ["--retries", "1", "lsf", "mx:"], { + env: { ...rcloneEnv, RCLONE_CONFIG_MX_PASS: "" }, + timeout: SPAWN_TIMEOUT, + }), + ).rejects.toThrow(); + }, + CASE_TIMEOUT, + ); +}); + +// --------------------------------------------------------------------------- +// curl +// --------------------------------------------------------------------------- + +describe.skipIf(curl === undefined)("curl against mountx/webdav", () => { + it( + "walks a resource through every method by hand", + async () => { + const base = `${server.url}/by-hand`; + expect(await curlRun("-o", "/dev/null", "-w", "%{http_code}", "-X", "MKCOL", base)).toBe( + "201", + ); + + const upload = join(await scratchDir("mountx-webdav-curl-"), "payload.txt"); + await writeFile(upload, "curl put this here"); + expect( + await curlRun("-o", "/dev/null", "-w", "%{http_code}", "-T", upload, `${base}/note.txt`), + ).toBe("201"); + expect(await readFile(join(root, "by-hand", "note.txt"), "utf8")).toBe("curl put this here"); + + const listing = await curlRun("-X", "PROPFIND", "-H", "Depth: 1", base); + expect(listing).toContain("/by-hand/"); + expect(listing).toContain("/by-hand/note.txt"); + expect(listing).toContain("18"); + + expect(await curlRun(`${base}/note.txt`)).toBe("curl put this here"); + expect(await curlRun("-H", "Range: bytes=0-3", `${base}/note.txt`)).toBe("curl"); + + expect( + await curlRun( + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "MOVE", + "-H", + `Destination: ${base}/renamed.txt`, + `${base}/note.txt`, + ), + ).toBe("201"); + expect(await readFile(join(root, "by-hand", "renamed.txt"), "utf8")).toBe( + "curl put this here", + ); + + expect(await curlRun("-o", "/dev/null", "-w", "%{http_code}", "-X", "DELETE", base)).toBe( + "204", + ); + await expect(stat(join(root, "by-hand"))).rejects.toThrow(); + }, + CASE_TIMEOUT, + ); + + it( + "takes a lock, refuses an untokened write, and lets the token through", + async () => { + /* The class-2 round trip as a client that shares none of this code sends + it: a LOCK body, a Lock-Token header back, a PUT that is refused + because it carries no token, the same PUT with an If header, and an + UNLOCK. curl is the right oracle here — it sends each method exactly as + written, with no backend deciding what a lock is for. */ + const target = `${server.url}/locked-by-curl.txt`; + const lock = await curlRun( + "-i", + "-o", + "-", + "-X", + "LOCK", + "-H", + `Content-Type: application/xml; charset="utf-8"`, + "--data-binary", + `` + + `` + + `curl`, + target, + ); + // §7.3: a LOCK on an unmapped URL creates the resource and answers 201. + expect(lock).toContain("201 Created"); + expect(lock.toLowerCase()).toContain("lock-token: curl"); + const token = /([^<]+)<\/href>/.exec(lock)?.[1] as string; + expect(token).toMatch(/^urn:uuid:/); + await stat(join(root, "locked-by-curl.txt")); + + const upload = join(await scratchDir("mountx-webdav-lock-"), "payload.txt"); + await writeFile(upload, "the locked bytes"); + expect(await curlRun("-o", "/dev/null", "-w", "%{http_code}", "-T", upload, target)).toBe( + "423", + ); + expect( + await curlRun( + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-H", + `If: (<${token}>)`, + "-T", + upload, + target, + ), + ).toBe("204"); + expect(await readFile(join(root, "locked-by-curl.txt"), "utf8")).toBe("the locked bytes"); + + expect( + await curlRun( + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "UNLOCK", + "-H", + `Lock-Token: <${token}>`, + target, + ), + ).toBe("204"); + // Unlocked, so the same write goes through with no token at all. + expect(await curlRun("-o", "/dev/null", "-w", "%{http_code}", "-T", upload, target)).toBe( + "204", + ); + }, + CASE_TIMEOUT, + ); + + it( + "advertises classes 1, 2 and 3, and says so to an unauthenticated client too", + async () => { + /* Field names go out lowercase — RFC 9110 §5.1 makes them + case-insensitive and HTTP/2 requires lowercase, which is also what the + S3 gateway sends — so the assertion is on the name as sent, not on a + canonical casing nothing promises. */ + const headers = await curlRun("-i", "-o", "-", "-X", "OPTIONS", `${server.url}/`); + expect(headers.toLowerCase()).toContain("dav: 1, 2, 3"); + expect(headers.toLowerCase()).toContain("ms-author-via: dav"); + // Class 2 is advertised, and the method list says the same thing. + expect(headers).toContain("LOCK"); + expect(headers).toContain("UNLOCK"); + + const { stdout } = await run( + curl as string, + ["--silent", "-o", "/dev/null", "-w", "%{http_code}", "-X", "OPTIONS", `${server.url}/`], + { timeout: SPAWN_TIMEOUT }, + ); + expect(stdout).toBe("401"); + }, + CASE_TIMEOUT, + ); +}); diff --git a/test/webdav/protocol.test.ts b/test/webdav/protocol.test.ts new file mode 100644 index 0000000..1451e8f --- /dev/null +++ b/test/webdav/protocol.test.ts @@ -0,0 +1,681 @@ +/** + * The WebDAV wire, on its own: no driver, no socket, no session. + * + * Three kinds of fact live here, and they are checked against different + * sources: + * + * - **RFC 4918's grammars** — `Depth`, `Overwrite`, `Destination`, `Timeout`, + * `Lock-Token`, the `If` header's disjunction-of-conjunctions, the three + * request bodies, and the response documents. The section is named at the + * assertion wherever the rule is not obvious from the shape. + * - **The path mapping**, in both directions and round-tripped. This is the + * security-relevant half: a target that escapes the driver root, a segment + * holding an encoded separator, and a malformed escape each have a pinned + * answer. + * - **Totality.** Every parser answers a `DavFault` or a value; none of them + * throw anything else, whatever they are given. + * + * Fixtures give every field a distinct value (`AGENTS.md`), and no literal + * control character appears in this file. + */ + +import { describe, expect, it } from "vitest"; +import { statusLine, STATUS_TEXT, statusOf } from "../../src/webdav/constants.ts"; +import { DavLockTable } from "../../src/webdav/locks.ts"; +import { + activeLockNode, + collectBody, + DavFault, + encodeErrorDocument, + encodeLockResponse, + encodeMultistatus, + faultResponse, + formatLockToken, + hrefOf, + isDavFault, + lockDiscoveryNode, + parseDepth, + parseDestination, + parseIf, + parseLockInfo, + parseLockToken, + parseOverwrite, + parsePropfind, + parseProppatch, + parseTargetPath, + parseTimeout, + refuse, + statusOfError, + submittedTokens, + supportedLockNode, +} from "../../src/webdav/protocol.ts"; +import { xmlDocument } from "../../src/s3/xml.ts"; + +/** The status a call refused with, or `undefined` if it did not refuse. */ +function refusedWith(fn: () => unknown): number | undefined { + try { + fn(); + } catch (error) { + return isDavFault(error) ? error.status : undefined; + } + return undefined; +} + +const utf8 = (text: string): Uint8Array => Buffer.from(text, "utf8"); + +// --------------------------------------------------------------------------- +// paths and hrefs +// --------------------------------------------------------------------------- + +describe("parseTargetPath", () => { + it("decodes one segment at a time", () => { + expect(parseTargetPath("/a%20b/c%C3%A9")).toBe("/a b/cé"); + }); + + it("drops the query and the fragment", () => { + expect(parseTargetPath("/a?b=c")).toBe("/a"); + expect(parseTargetPath("/a#b")).toBe("/a"); + }); + + it("collapses empty segments and normalizes", () => { + expect(parseTargetPath("//a//b/")).toBe("/a/b"); + expect(parseTargetPath("/")).toBe("/"); + expect(parseTargetPath("/a/./b")).toBe("/a/b"); + }); + + it("clamps `..` at the root rather than escaping it", () => { + /* `normalizePath`'s rule, and the reason there is no traversal check + anywhere in the session: there is nothing above `/` to reach. */ + expect(parseTargetPath("/../etc/passwd")).toBe("/etc/passwd"); + expect(parseTargetPath("/%2e%2e/%2e%2e/etc")).toBe("/etc"); + expect(parseTargetPath("/a/../../b")).toBe("/b"); + }); + + it("refuses an encoded separator rather than reading it as one", () => { + /* The difference from the S3 gateway: an S3 key may contain a `/`, a POSIX + name may not, so `%2F` is a resource this server does not have. */ + expect(refusedWith(() => parseTargetPath("/a%2Fb"))).toBe(400); + }); + + it("refuses a malformed escape, a NUL and a target that is not a path", () => { + for (const target of ["/a%", "/a%zz", "/a%C3%28", "/a%00b", "a/b", "http://x/y", "*"]) { + expect( + refusedWith(() => parseTargetPath(target)), + target, + ).toBe(400); + } + }); +}); + +describe("hrefOf", () => { + it("encodes each segment and marks a collection with a trailing slash", () => { + expect(hrefOf("/a b/c", false)).toBe("/a%20b/c"); + expect(hrefOf("/a b/c", true)).toBe("/a%20b/c/"); + expect(hrefOf("/", true)).toBe("/"); + expect(hrefOf("/", false)).toBe("/"); + }); + + it("encodes the characters that would otherwise re-parse as syntax", () => { + expect(hrefOf("/a?b", false)).toBe("/a%3Fb"); + expect(hrefOf("/a#b", false)).toBe("/a%23b"); + expect(hrefOf("/a%b", false)).toBe("/a%25b"); + }); + + it("round-trips every name a driver can hold", () => { + for (const name of ["plain", "a b", "café", "a?b", "a#b", "a%2Fb", "a;b", "a&b", "'"]) { + expect(parseTargetPath(hrefOf(`/dir/${name}`, false)), name).toBe(`/dir/${name}`); + } + }); +}); + +// --------------------------------------------------------------------------- +// headers +// --------------------------------------------------------------------------- + +describe("parseDepth", () => { + it("reads the three legal values, case-insensitively", () => { + expect(parseDepth("0", 1)).toBe(0); + expect(parseDepth("1", 0)).toBe(1); + expect(parseDepth("Infinity", 0)).toBe("infinity"); + expect(parseDepth(" infinity ", 0)).toBe("infinity"); + }); + + it("uses the caller's default only when the header is absent", () => { + expect(parseDepth(undefined, "infinity")).toBe("infinity"); + expect(parseDepth("2", 1)).toBeUndefined(); + expect(parseDepth("", 1)).toBeUndefined(); + }); +}); + +describe("parseOverwrite", () => { + it("defaults to T and reads both flags", () => { + expect(parseOverwrite(undefined)).toBe(true); + expect(parseOverwrite("T")).toBe(true); + expect(parseOverwrite("f")).toBe(false); + expect(parseOverwrite("yes")).toBeUndefined(); + }); +}); + +describe("parseDestination", () => { + it("takes an absolute URI on the same origin", () => { + expect(parseDestination("http://dav.example:8080/a%20b", "dav.example:8080")).toBe("/a b"); + expect(parseDestination("http://DAV.example/a", "dav.example")).toBe("/a"); + }); + + it("takes an absolute path, which is what several clients send", () => { + expect(parseDestination("/a/b", "dav.example")).toBe("/a/b"); + }); + + it("refuses another origin with 502, per §9.9.4", () => { + expect(refusedWith(() => parseDestination("http://elsewhere/a", "dav.example"))).toBe(502); + /* No `Host` to compare against: the authority cannot be checked, so it is + not assumed to be local. */ + expect(refusedWith(() => parseDestination("http://dav.example/a", undefined))).toBe(502); + }); + + it("refuses a missing or unparseable header with 400", () => { + expect(refusedWith(() => parseDestination(undefined, "x"))).toBe(400); + expect(refusedWith(() => parseDestination(" ", "x"))).toBe(400); + expect(refusedWith(() => parseDestination("not a uri", "x"))).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// request bodies +// --------------------------------------------------------------------------- + +describe("collectBody", () => { + it("joins the chunks and copies each one", async () => { + const buffer = Buffer.from("abcd", "utf8"); + const body = (async function* () { + yield buffer.subarray(0, 2); + yield buffer.subarray(2); + })(); + const collected = await collectBody(body); + buffer.fill(0x2e); + // The copy is what survives the caller reusing its buffer. + expect(Buffer.from(collected).toString("utf8")).toBe("abcd"); + }); + + it("refuses a body over the budget with 413", async () => { + const body = (async function* () { + yield utf8("0123456789"); + })(); + await expect(collectBody(body, 4)).rejects.toBeInstanceOf(DavFault); + }); +}); + +describe("parsePropfind", () => { + it("reads an empty body as allprop, which §9.1 requires", () => { + expect(parsePropfind(new Uint8Array(0))).toEqual({ kind: "allprop" }); + }); + + it("reads the three forms whatever prefix they use", () => { + expect(parsePropfind(utf8(``))).toEqual({ + kind: "allprop", + }); + expect(parsePropfind(utf8(``))).toEqual({ + kind: "propname", + }); + expect( + parsePropfind( + utf8( + `` + + ``, + ), + ), + ).toEqual({ kind: "prop", names: ["getetag", "resourcetype"] }); + }); + + it("keeps each name once, in request order", () => { + expect( + parsePropfind(utf8(``)), + ).toEqual({ kind: "prop", names: ["a", "b"] }); + }); + + it("refuses a body that is not a propfind", () => { + for (const body of [ + ``, + ``, + ``, + ]) { + expect( + refusedWith(() => parsePropfind(utf8(body))), + body, + ).toBe(400); + } + }); +}); + +describe("parseProppatch", () => { + it("keeps both lists, because the reply must name every property", () => { + expect( + parseProppatch( + utf8( + `` + + `x` + + `` + + ``, + ), + ), + ).toEqual({ set: [{ name: "displayname", text: "x" }], remove: ["mine"] }); + }); + + it("ignores a set child that is not a prop, and a child that is neither set nor remove", () => { + expect( + parseProppatch( + utf8( + `` + + `v`, + ), + ), + ).toEqual({ set: [{ name: "displayname", text: "v" }], remove: [] }); + }); + + it("ignores a child that is neither set nor remove", () => { + expect( + parseProppatch( + utf8( + `` + + ``, + ), + ), + ).toEqual({ set: [{ name: "displayname", text: "" }], remove: [] }); + }); + + it("refuses a body naming nothing", () => { + expect(refusedWith(() => parseProppatch(utf8(``)))).toBe(400); + expect( + refusedWith(() => + parseProppatch(utf8(``)), + ), + ).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// refusals and documents +// --------------------------------------------------------------------------- + +describe("statusOfError", () => { + it("takes a fault's own status", () => { + expect(statusOfError(refuse(423))).toBe(423); + }); + + it("maps a driver errno through the table", () => { + expect(statusOfError(Object.assign(new Error("x"), { code: "ENOENT" }))).toBe(404); + expect(statusOfError(Object.assign(new Error("x"), { code: "ENOTEMPTY" }))).toBe(409); + expect(statusOfError(Object.assign(new Error("x"), { code: "ENOSPC" }))).toBe(507); + }); + + it("answers 500 for anything it cannot name", () => { + expect(statusOfError(new Error("x"))).toBe(500); + expect(statusOfError("nonsense")).toBe(500); + expect(statusOfError(undefined)).toBe(500); + expect(statusOf("EWHAT")).toBe(500); + }); +}); + +describe("faultResponse", () => { + it("carries the fault's extra headers and no body", () => { + const reply = faultResponse(refuse(405, { headers: { allow: "GET" } })); + expect(reply).toEqual({ + status: 405, + headers: { allow: "GET", "content-length": "0" }, + }); + }); + + it("renders an error document when there is a condition to carry", () => { + const reply = faultResponse(refuse(403, { condition: "propfind-finite-depth" })); + expect(reply.status).toBe(403); + expect(Buffer.from(reply.body as Uint8Array).toString("utf8")).toBe( + `` + + ``, + ); + expect(reply.headers["content-length"]).toBe(String((reply.body as Uint8Array).byteLength)); + }); +}); + +describe("encodeMultistatus", () => { + it("writes a propstat response", () => { + expect( + encodeMultistatus([ + { + href: "/a%20b", + propstat: [ + { status: 200, props: [{ name: "getcontentlength", text: 11 }] }, + { status: 404, props: [{ name: "missing" }] }, + ], + }, + ]), + ).toBe( + `` + + `/a%20b` + + `11` + + `HTTP/1.1 200 OK` + + `` + + `HTTP/1.1 404 Not Found` + + ``, + ); + }); + + it("writes a bare-status response, which is what a partial DELETE answers", () => { + expect(encodeMultistatus([{ href: "/x/", status: 403 }])).toContain( + `/x/HTTP/1.1 403 Forbidden`, + ); + }); + + it("escapes an href rather than letting a name close an element", () => { + /* The href is built by `hrefOf`, which percent-encodes — this is the second + line of defence, and the one that holds if a caller ever passes a raw + name. */ + expect(encodeMultistatus([{ href: "/a&c", status: 200 }])).toContain( + `/a<b>&c`, + ); + }); + + it("carries a condition inside the propstat that needs one", () => { + expect( + encodeMultistatus([ + { + href: "/x", + propstat: [{ status: 403, props: [], condition: "cannot-modify-protected-property" }], + }, + ]), + ).toContain( + `HTTP/1.1 403 Forbidden` + + ``, + ); + }); +}); + +describe("statusLine", () => { + it("names every status this server can send", () => { + expect(statusLine(207)).toBe("HTTP/1.1 207 Multi-Status"); + expect(statusLine(507)).toBe("HTTP/1.1 507 Insufficient Storage"); + for (const status of Object.keys(STATUS_TEXT)) { + expect(statusLine(Number(status))).toMatch(/^HTTP\/1\.1 \d{3} \S/); + } + }); + + it("still renders a status with no phrase", () => { + expect(statusLine(499)).toBe("HTTP/1.1 499"); + }); +}); + +describe("encodeErrorDocument", () => { + it("is a DAV:-namespaced error with one condition", () => { + expect(encodeErrorDocument("lock-token-submitted")).toBe( + `` + + ``, + ); + }); +}); + +// --------------------------------------------------------------------------- +// the locking headers +// --------------------------------------------------------------------------- + +describe("parseTimeout", () => { + it("reads the first TimeType of §10.7's list", () => { + expect(parseTimeout("Second-3600")).toBe(3600); + expect(parseTimeout("Infinite")).toBe("infinite"); + expect(parseTimeout("second-90")).toBe(90); + // §9.10.7's own header: the first entry is the one that is read. + expect(parseTimeout("Infinite, Second-4100000000")).toBe("infinite"); + expect(parseTimeout(" Second-5 , Infinite ")).toBe(5); + }); + + it("ignores an absent or unreadable suggestion rather than refusing it", () => { + /* §6.6 makes the value a suggestion the server may ignore entirely, so a + malformed one is one more thing to ignore. */ + for (const value of [undefined, "", "Seconds-30", "Second-", "Second-1x", "forever"]) { + expect(parseTimeout(value), JSON.stringify(value)).toBeUndefined(); + } + }); +}); + +describe("the Lock-Token header", () => { + it("is a Coded-URL in both directions (§10.5)", () => { + expect(parseLockToken("")).toBe( + "urn:uuid:e71d4fae-5dec-22d6-fea5-00a0c91e6be4", + ); + expect(parseLockToken(" ")).toBe("urn:uuid:a"); + expect(formatLockToken("urn:uuid:a")).toBe(""); + }); + + it("is undefined for anything that is not one", () => { + for (const value of [undefined, "", "urn:uuid:a", "", "<>", ""]) { + expect(parseLockToken(value), JSON.stringify(value)).toBeUndefined(); + } + }); +}); + +describe("parseIf", () => { + const host = "example.com"; + + it("reads §10.4.6's no-tag production as OR over AND", () => { + expect(parseIf(`( ["I am an ETag"]) (["I am another ETag"])`, host)).toEqual( + [ + { + resource: undefined, + foreign: false, + conditions: [ + { negated: false, token: "urn:uuid:181d4fae" }, + { negated: false, etag: `"I am an ETag"` }, + ], + }, + { + resource: undefined, + foreign: false, + conditions: [{ negated: false, etag: `"I am another ETag"` }], + }, + ], + ); + }); + + it("applies Not to the one condition after it (§10.4.7)", () => { + expect(parseIf(`(Not )`, host)?.[0]?.conditions).toEqual( + [ + { negated: true, token: "urn:uuid:181d4fae" }, + { negated: false, token: "urn:uuid:58f202ac" }, + ], + ); + }); + + it("carries a Resource-Tag forward to every list until the next one", () => { + const lists = parseIf(` () () ()`, host); + expect(lists?.map((list) => [list.resource, list.conditions[0]?.token])).toEqual([ + ["/one", "urn:uuid:a"], + ["/one", "urn:uuid:b"], + ["/two/deep", "urn:uuid:c"], + ]); + }); + + it("takes an absolute URI on this origin, and decodes it per segment", () => { + expect(parseIf(` ()`, host)?.[0]?.resource).toBe("/a b"); + }); + + it("marks another origin foreign rather than refusing it (§10.4.4)", () => { + /* A URL this server does not serve is a resource whose state it cannot + know, which is the "handling unmapped URLs" case rather than an error. */ + for (const tag of ["http://elsewhere.example/x", "not a uri", "http:"]) { + const list = parseIf(`<${tag}> ()`, host)?.[0]; + expect(list, tag).toMatchObject({ resource: undefined, foreign: true }); + } + // No `Host` to compare against: an absolute URI cannot be shown to be local. + expect(parseIf(` ()`, undefined)?.[0]?.foreign).toBe(true); + }); + + it("accepts tagged and untagged lists in one header", () => { + /* §10.4.2 says they cannot be mixed, and says why it costs nothing to read + one that does: an untagged list is shorthand for a tagged one naming the + request URI. */ + expect( + parseIf(`() ()`, host)?.map((list) => list.resource), + ).toEqual([undefined, "/x"]); + }); + + it("keeps an entity tag as it was sent, weakness marker and brackets included", () => { + const lists = parseIf(`( [W/"weak"]) ([")]("])`, host); + expect(lists?.[0]?.conditions[1]).toEqual({ negated: false, etag: `W/"weak"` }); + // A `]` inside the quoted value does not end the tag. + expect(lists?.[1]?.conditions[0]).toEqual({ negated: false, etag: `")]("` }); + }); + + it("is undefined for anything that is not §10.4.2's grammar", () => { + for (const value of [ + "", + " ", + "(", + "()", + "", + "()", + " { + /* §10.4.1: a token counts as submitted whatever the list evaluated to — + but `Not ` asserts the resource is *not* held by it, which is the + opposite of a claim, and is what makes §10.4.8's `(Not )` + idiom a tautology rather than a claim on a lock. */ + const lists = parseIf( + `( ["etag"]) () () (Not )`, + host, + ); + expect(submittedTokens(lists ?? [])).toEqual(["urn:uuid:a", "urn:uuid:b"]); + }); +}); + +// --------------------------------------------------------------------------- +// the lock bodies and documents +// --------------------------------------------------------------------------- + +describe("parseLockInfo", () => { + const lockinfo = (inner: string): Uint8Array => + utf8(`${inner}`); + + it("reads §9.10.7's request", () => { + expect( + parseLockInfo( + lockinfo( + `` + + `` + + `http://example.org/~ejw/contact.html`, + ), + ), + ).toEqual({ + exclusive: true, + owner: { + name: "owner", + text: undefined, + children: [{ name: "href", text: "http://example.org/~ejw/contact.html", children: [] }], + }, + }); + }); + + it("reads a shared lock, and an owner that is text", () => { + expect( + parseLockInfo( + lockinfo( + `` + + `Ada Lovelace`, + ), + ), + ).toEqual({ + exclusive: false, + owner: { name: "owner", text: "Ada Lovelace", children: [] }, + }); + }); + + it("is undefined for the empty body that means refresh (§7.7)", () => { + expect(parseLockInfo(new Uint8Array(0))).toBeUndefined(); + }); + + it("refuses a scope or a type this server has no meaning for", () => { + expect( + refusedWith(() => parseLockInfo(lockinfo(``))), + ).toBe(400); + expect( + refusedWith(() => + parseLockInfo( + lockinfo(``), + ), + ), + ).toBe(400); + expect(refusedWith(() => parseLockInfo(utf8("")))).toBe(400); + expect(refusedWith(() => parseLockInfo(utf8(" { + const now = 1_700_000_000_000; + const table = new DavLockTable({ newToken: () => "urn:uuid:fixed-token" }); + const grant = table.create( + { + path: "/a b/notes", + collection: true, + depth: "infinity", + exclusive: true, + owner: { name: "owner", children: [{ name: "href", text: "mailto:ada@example.com" }] }, + timeoutSeconds: 120, + }, + now, + ); + const lock = grant.kind === "granted" ? grant.lock : undefined; + + it("writes §14.1's children in the DTD's order", () => { + expect(xmlDocument(activeLockNode(lock!, now + 5000))).toBe( + `` + + `` + + `` + + `` + + `infinity` + + `mailto:ada@example.com` + + `Second-115` + + `urn:uuid:fixed-token` + + `/a%20b/notes/` + + ``, + ); + }); + + it("is the whole §9.10.1 body: a prop holding one lockdiscovery", () => { + expect(encodeLockResponse(lock!, now)).toBe( + `` + + `${xmlDocument(activeLockNode(lock!, now)).slice( + ``.length, + )}`, + ); + }); + + it("is an empty lockdiscovery when nothing is locked (§15.8)", () => { + expect(xmlDocument(lockDiscoveryNode([], now))).toContain(``); + }); + + it("advertises exactly the two lock entries §15.10 defines", () => { + expect(xmlDocument(supportedLockNode())).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + }); + + it("names the locked resource inside the condition (§7.5.2, §16)", () => { + expect(encodeErrorDocument("lock-token-submitted", ["/locked/"])).toBe( + `` + + `/locked/` + + ``, + ); + }); +}); diff --git a/test/webdav/server.test.ts b/test/webdav/server.test.ts new file mode 100644 index 0000000..bf7c837 --- /dev/null +++ b/test/webdav/server.test.ts @@ -0,0 +1,457 @@ +/** + * The WebDAV server over a real socket: `createWebdavServer()` and `fetch`. + * + * Everything below `server.ts` is already pinned in-process by + * `session.test.ts` and `protocol.test.ts`, so what is checked here is only + * what a socket adds: + * + * - **The bind gate.** Without credentials the share is loopback-only, and the + * refusal happens in `createWebdavServer()` — synchronously, before a server + * object exists, which is what "nothing listened" means here. + * - **A round trip a real client would make**: `OPTIONS`, `MKCOL`, `PUT`, + * `PROPFIND`, `GET`, `MOVE`, `DELETE`, over one keep-alive connection. + * - **Framing.** `HEAD` carrying headers and no bytes, and the one message HTTP + * cannot recover from — a body that ends short of its `Content-Length`, which + * must take the connection with it rather than leave a client waiting. + * - **Streaming.** A multi-MiB `GET` arriving whole, and an abandoned download + * releasing the file handle it opened. + * - **`close()`.** In-flight responses finish, and the second call is free. + * + * Every port is ephemeral (`port: 0`), every wait is bounded, and no literal + * control character appears in this file. + */ + +import { connect } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { createMemoryDriver } from "../../src/drivers/memory.ts"; +import type { FileHandleLike, FsDriver } from "../../src/types.ts"; +import { + bindRefusal, + createWebdavServer, + isLoopbackHost, + isWebdavBindError, + WebdavBindError, + type WebdavServer, + type WebdavServerOptions, +} from "../../src/webdav/server.ts"; + +// --------------------------------------------------------------------------- +// fixtures and helpers +// --------------------------------------------------------------------------- + +const CRLF = "\r\n"; +/** A body big enough that the reply is streamed in several pieces. */ +const BIG = 512 * 1024; + +/** Every server this file started, closed after each test whatever happened. */ +const running: WebdavServer[] = []; + +afterEach(async () => { + for (const server of running.splice(0)) { + await server.close(); + } +}); + +async function serve( + driver: FsDriver = createMemoryDriver(), + options: WebdavServerOptions = {}, +): Promise { + const server = createWebdavServer(driver, options); + running.push(server); + await server.listen(); + return server; +} + +async function until(predicate: () => boolean, label: string, timeout = 2000): Promise { + const deadline = Date.now() + timeout; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${label}`); + } + await delay(5); + } +} + +/** Bytes that are not all the same, so a truncation cannot pass as a match. */ +function pattern(size: number): Uint8Array { + const bytes = new Uint8Array(size); + for (let index = 0; index < size; index++) { + bytes[index] = (index * 31 + (index >> 8)) & 0xff; + } + return bytes; +} + +/** + * A `fetch` body from bytes. `BodyInit` does not accept a plain `Uint8Array` + * under this TypeScript's DOM types, and a `Blob` keeps the `Content-Length`. + */ +function bodyOf(bytes: Uint8Array): BodyInit { + return new Blob([new Uint8Array(bytes)]); +} + +/** A driver that counts the handles it opens and closes. */ +function counting(inner: FsDriver): { + driver: FsDriver; + counts: { opened: number; closed: number }; +} { + const counts = { opened: 0, closed: 0 }; + const driver: FsDriver = { + ...inner, + open: async (path, flags, mode): Promise => { + const handle = await inner.open(path, flags, mode); + counts.opened++; + return { + ...handle, + read: async (buffer, offset, length, position) => + await handle.read(buffer, offset, length, position), + close: async () => { + counts.closed++; + await handle.close(); + }, + }; + }, + }; + return { driver, counts }; +} + +/** + * A driver whose reads stop delivering after `deliver` bytes, while `stat` + * keeps reporting the whole size — the TOCTOU a passthrough driver lives with, + * and the one reply the transport has to notice is out of frame. + */ +function truncating(inner: FsDriver, name: string, deliver: number): FsDriver { + return { + ...inner, + open: async (path, flags, mode): Promise => { + const handle = await inner.open(path, flags, mode); + if (!path.endsWith(name)) { + return handle; + } + return { + ...handle, + read: async (buffer, offset, length, position) => { + const at = position ?? 0; + if (at >= deliver) { + return { bytesRead: 0, buffer }; + } + const capped = Math.min(length ?? buffer.byteLength, deliver - at); + return await handle.read(buffer, offset, capped, position); + }, + close: async () => await handle.close(), + }; + }, + }; +} + +/** One raw request on its own socket, answered as text. */ +function raw(server: WebdavServer, lines: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(server.port, server.host, () => socket.write(lines)); + let received = ""; + socket.setTimeout(2000, () => socket.destroy(new Error("raw request timed out"))); + socket.on("data", (chunk: Buffer) => { + received += chunk.toString("utf8"); + }); + socket.on("close", () => resolve(received)); + socket.on("error", reject); + }); +} + +// --------------------------------------------------------------------------- +// the bind gate +// --------------------------------------------------------------------------- + +describe("createWebdavServer: the bind gate", () => { + it("accepts every literal loopback spelling and nothing else", () => { + for (const host of [ + "localhost", + "LOCALHOST", + "127.0.0.1", + "127.9.9.9", + "::1", + "[::1]", + "::ffff:127.0.0.1", + ]) { + expect(isLoopbackHost(host), host).toBe(true); + } + for (const host of ["", "0.0.0.0", "::", "[::]", "10.0.0.1", "127.0.0.256", "::1]"]) { + expect(isLoopbackHost(host), host).toBe(false); + } + }); + + it("names the unspecified addresses as the danger they are", () => { + expect(bindRefusal("0.0.0.0", false)).toContain("binds every interface"); + expect(bindRefusal("", false)).toContain("every interface"); + expect(bindRefusal("192.168.1.10", false)).toContain("reachable from outside"); + expect(bindRefusal("127.0.0.1", false)).toBeUndefined(); + }); + + it("refuses a non-loopback bind with no credentials, before anything listens", () => { + let thrown: unknown; + try { + createWebdavServer(createMemoryDriver(), { host: "0.0.0.0" }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(WebdavBindError); + expect(isWebdavBindError(thrown)).toBe(true); + expect((thrown as WebdavBindError).host).toBe("0.0.0.0"); + expect((thrown as WebdavBindError).code).toBe("ERR_WEBDAV_BIND"); + }); + + it("allows any bind once credentials are configured", () => { + const server = createWebdavServer(createMemoryDriver(), { + host: "0.0.0.0", + credentials: { username: "ada", password: "secret" }, + }); + expect(server.host).toBe("0.0.0.0"); + expect(server.port).toBe(0); + }); + + it("refuses a value that is not a driver", () => { + expect(() => createWebdavServer({} as FsDriver)).toThrow(TypeError); + }); + + it("brackets an IPv6 host in the URL", () => { + expect(createWebdavServer(createMemoryDriver(), { host: "::1" }).url).toBe("http://[::1]:0"); + }); +}); + +// --------------------------------------------------------------------------- +// the round trip +// --------------------------------------------------------------------------- + +describe("createWebdavServer: a client's session", () => { + it("walks a whole tree through fetch", async () => { + const server = await serve(); + + const options = await fetch(`${server.url}/`, { method: "OPTIONS" }); + expect(options.status).toBe(200); + expect(options.headers.get("dav")).toBe("1, 2, 3"); + + expect((await fetch(`${server.url}/notes`, { method: "MKCOL" })).status).toBe(201); + + const put = await fetch(`${server.url}/notes/a%20b.txt`, { + method: "PUT", + body: "the body", + }); + expect(put.status).toBe(201); + expect(put.headers.get("etag")).toMatch(/^"[\da-f]{32}"$/); + + const propfind = await fetch(`${server.url}/notes`, { + method: "PROPFIND", + headers: { depth: "1" }, + }); + expect(propfind.status).toBe(207); + expect(propfind.headers.get("content-type")).toBe('application/xml; charset="utf-8"'); + const listing = await propfind.text(); + expect(listing).toContain("/notes/"); + expect(listing).toContain("/notes/a%20b.txt"); + + const get = await fetch(`${server.url}/notes/a%20b.txt`); + expect(await get.text()).toBe("the body"); + + const move = await fetch(`${server.url}/notes/a%20b.txt`, { + method: "MOVE", + headers: { destination: `${server.url}/notes/renamed.txt` }, + }); + expect(move.status).toBe(201); + expect((await fetch(`${server.url}/notes/renamed.txt`)).status).toBe(200); + + expect((await fetch(`${server.url}/notes`, { method: "DELETE" })).status).toBe(204); + expect((await fetch(`${server.url}/notes/renamed.txt`)).status).toBe(404); + expect(server.session.assertions).toEqual([]); + }); + + it("challenges an unauthenticated request and serves an authenticated one", async () => { + const server = await serve(createMemoryDriver(), { + credentials: { username: "ada", password: "secret" }, + }); + const anonymous = await fetch(`${server.url}/`, { method: "OPTIONS" }); + expect(anonymous.status).toBe(401); + expect(anonymous.headers.get("www-authenticate")).toContain("Basic realm="); + + const authorization = `Basic ${Buffer.from("ada:secret", "utf8").toString("base64")}`; + const allowed = await fetch(`${server.url}/`, { + method: "OPTIONS", + headers: { authorization }, + }); + expect(allowed.status).toBe(200); + }); + + it("disposes with `await using`", async () => { + let url: string; + { + await using server = await createWebdavServer(createMemoryDriver()).listen(); + url = server.url; + expect((await fetch(`${url}/`, { method: "OPTIONS" })).status).toBe(200); + } + await expect(fetch(`${url}/`, { method: "OPTIONS" })).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// what the wire adds +// --------------------------------------------------------------------------- + +describe("createWebdavServer: framing", () => { + it("carries a HEAD reply's content-length with no body", async () => { + const server = await serve(); + await fetch(`${server.url}/f.txt`, { method: "PUT", body: "0123456789" }); + const head = await fetch(`${server.url}/f.txt`, { method: "HEAD" }); + expect(head.headers.get("content-length")).toBe("10"); + expect(await head.text()).toBe(""); + }); + + it("reuses one keep-alive connection for two requests", async () => { + const server = await serve(); + const answer = await raw( + server, + `OPTIONS / HTTP/1.1${CRLF}host: dav.test${CRLF}${CRLF}` + + `PROPFIND / HTTP/1.1${CRLF}host: dav.test${CRLF}depth: 0${CRLF}connection: close${CRLF}${CRLF}`, + ); + /* Two replies, in order, on one socket. Counted by status *line* rather + than by substring: a `207`'s body carries `HTTP/1.1 200 OK` inside every + propstat, which is the same text in a different place. */ + const statusLines = answer.split(CRLF).filter((line) => line.startsWith("HTTP/1.1 ")); + expect(statusLines).toEqual(["HTTP/1.1 200 OK", "HTTP/1.1 207 Multi-Status"]); + }); + + it("drains a body the session never read", async () => { + /* A `DELETE` that carried one: the bytes have to leave the socket or the + next request on it cannot be framed. */ + const server = await serve(); + await fetch(`${server.url}/f.txt`, { method: "PUT", body: "x" }); + const answer = await raw( + server, + `DELETE /f.txt HTTP/1.1${CRLF}host: dav.test${CRLF}content-length: 5${CRLF}${CRLF}hello` + + `OPTIONS / HTTP/1.1${CRLF}host: dav.test${CRLF}connection: close${CRLF}${CRLF}`, + ); + expect(answer).toContain("HTTP/1.1 204 No Content"); + expect(answer).toContain("HTTP/1.1 200 OK"); + }); + + it("kills the connection when a body ends short of its content-length", async () => { + const server = await serve(truncating(createMemoryDriver(), "short.bin", 1024), { + readChunkBytes: 512, + onTransportError: () => undefined, + }); + await fetch(`${server.url}/short.bin`, { method: "PUT", body: bodyOf(pattern(4096)) }); + /* The reply promises 4096 bytes and can produce 1024. A client must not be + left waiting for the rest, so the connection goes. */ + await expect( + fetch(`${server.url}/short.bin`).then(async (response) => await response.arrayBuffer()), + ).rejects.toThrow(); + expect(server.session.assertions).toEqual([]); + }); +}); + +describe("createWebdavServer: streaming", () => { + it("streams a multi-MiB GET, and every byte arrives", async () => { + const server = await serve(createMemoryDriver(), { readChunkBytes: 16 * 1024 }); + const bytes = pattern(BIG); + await fetch(`${server.url}/big.bin`, { method: "PUT", body: bodyOf(bytes) }); + const response = await fetch(`${server.url}/big.bin`); + expect(response.headers.get("content-length")).toBe(String(BIG)); + expect(Buffer.from(await response.arrayBuffer()).equals(Buffer.from(bytes))).toBe(true); + }); + + it("releases the handle when a download is abandoned", async () => { + const { driver, counts } = counting(createMemoryDriver()); + const server = await serve(driver, { readChunkBytes: 16 * 1024 }); + await fetch(`${server.url}/abandoned.bin`, { method: "PUT", body: bodyOf(pattern(BIG)) }); + const afterPut = counts.opened; + + const controller = new AbortController(); + const response = await fetch(`${server.url}/abandoned.bin`, { signal: controller.signal }); + const reader = (response.body as ReadableStream).getReader(); + await reader.read(); + expect(counts.opened).toBe(afterPut + 1); + controller.abort(); + await reader.cancel().catch(() => undefined); + + // The generator's `finally` runs on the transport's `return()`, not before. + await until(() => counts.closed === counts.opened, "the abandoned handle to close"); + expect((await fetch(`${server.url}/abandoned.bin`, { method: "HEAD" })).status).toBe(200); + expect(server.session.assertions).toEqual([]); + }); + + it("closes the body of a reply the client is no longer there for", async () => { + /* Aborted before the reply was even written: `#write` finds the response + destroyed, and the `GET` generator it was handed still has to be closed + or the handle leaks. The delay is in `open`, so the abort lands between + the session opening the resource and the transport writing a byte. */ + const inner = createMemoryDriver(); + const counts = { opened: 0, closed: 0 }; + const driver: FsDriver = { + ...inner, + open: async (path, flags, mode): Promise => { + const handle = await inner.open(path, flags, mode); + counts.opened++; + if (path.endsWith("/late.bin")) { + await delay(150); + } + return { + ...handle, + close: async () => { + counts.closed++; + await handle.close(); + }, + }; + }, + }; + const server = await serve(driver); + await fetch(`${server.url}/late.bin`, { method: "PUT", body: "read too late" }); + + const controller = new AbortController(); + const pending = fetch(`${server.url}/late.bin`, { signal: controller.signal }); + await delay(30); + controller.abort(); + await expect(pending).rejects.toThrow(); + + await until(() => counts.closed === counts.opened, "the unsent reply to close its handle"); + expect(server.session.assertions).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// close +// --------------------------------------------------------------------------- + +describe("createWebdavServer: close", () => { + it("lets an in-flight response finish", async () => { + const server = await serve(createMemoryDriver(), { readChunkBytes: 8 * 1024 }); + const bytes = pattern(BIG); + await fetch(`${server.url}/big.bin`, { method: "PUT", body: bodyOf(bytes) }); + /* Awaited to the headers, so the connection exists and the body is still + streaming — the socket is not idle, so it survives the sweep and only the + deadline could cut it. */ + const response = await fetch(`${server.url}/big.bin`); + const closing = server.close(); + const body = await response.arrayBuffer(); + await closing; + expect(body.byteLength).toBe(BIG); + }); + + it("stops answering, and the second close is free", async () => { + const server = await serve(); + const url = server.url; + await server.close(); + await server.close(); + await expect(fetch(`${url}/`, { method: "OPTIONS" })).rejects.toThrow(); + expect(server.connections).toBe(0); + }); + + it("closes a server that never listened", async () => { + const server = createWebdavServer(createMemoryDriver()); + await expect(server.close()).resolves.toBeUndefined(); + }); + + it("rejects a listen that cannot have the port", async () => { + const first = await serve(); + const second = createWebdavServer(createMemoryDriver(), { port: first.port }); + running.push(second); + await expect(second.listen()).rejects.toThrow(); + }); +}); diff --git a/test/webdav/session.test.ts b/test/webdav/session.test.ts new file mode 100644 index 0000000..5832fc1 --- /dev/null +++ b/test/webdav/session.test.ts @@ -0,0 +1,1678 @@ +/** + * The WebDAV session, against the memory driver, in process, with no sockets. + * + * Three kinds of assertion, and they answer to different authorities: + * + * - **RFC 4918's semantics.** What each method answers, and — where the RFC + * makes the choice and a plain HTTP server would choose differently — *why*: + * `PUT` under a missing parent is `409` and never `404`, an overwriting + * `COPY` deletes the destination first, a partial `DELETE` answers `207`, + * `PROPFIND` with no `Depth` is `infinity` and therefore refused. + * - **HTTP.** `Range`, `HEAD`, and the status/`Allow` pairs, which are + * RFC 9110's rather than WebDAV's. + * - **The one-reply discipline.** A driver that throws an errno nothing has a + * name for, a body source that dies, and a method that does not exist each + * produce exactly one well-formed reply, and the session answers the next + * request normally. + * + * The fixtures give every field a distinct value (`AGENTS.md`), and no literal + * control character appears in this file. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { createMemoryDriver } from "../../src/drivers/memory.ts"; +import { fsError } from "../../src/errors.ts"; +import { S_IFIFO } from "../../src/types.ts"; +import type { FsDriver, StatsFsLike } from "../../src/types.ts"; +import { WebdavSession } from "../../src/webdav/session.ts"; +import type { WebdavResponse } from "../../src/webdav/protocol.ts"; + +// --------------------------------------------------------------------------- +// the harness +// --------------------------------------------------------------------------- + +interface Reply { + status: number; + headers: Record; + text: string; +} + +/** Drain a reply's body, whichever of the three shapes it is. */ +async function bodyText(reply: WebdavResponse): Promise { + if (reply.body === undefined) { + return ""; + } + if (reply.body instanceof Uint8Array) { + return Buffer.from(reply.body).toString("utf8"); + } + let text = ""; + for await (const chunk of reply.body) { + text += Buffer.from(chunk).toString("utf8"); + } + return text; +} + +async function* stream(body: string | Uint8Array): AsyncGenerator { + yield typeof body === "string" ? Buffer.from(body, "utf8") : body; +} + +function request( + session: WebdavSession, + method: string, + target: string, + options: { headers?: Record; body?: string | Uint8Array } = {}, +): Promise { + const head = { method, target, headers: options.headers ?? {} }; + const body = options.body === undefined ? undefined : stream(options.body); + return session.handleRequest(head, body).then(async (reply) => ({ + status: reply.status, + headers: reply.headers, + text: await bodyText(reply), + })); +} + +/** The ``s of a multistatus, in document order. */ +function hrefs(document: string): string[] { + return [...document.matchAll(/([^<]*)<\/href>/g)].map((match) => match[1] as string); +} + +/** The propstat status codes of a multistatus, in document order. */ +function statuses(document: string): number[] { + return [...document.matchAll(/HTTP\/1\.1 (\d{3})/g)].map((match) => Number(match[1])); +} + +/** The text of one property element, or `undefined` if it is not there. */ +function property(document: string, name: string): string | undefined { + return new RegExp(`<${name}>([^<]*)`).exec(document)?.[1]; +} + +let driver: ReturnType; +let session: WebdavSession; + +beforeEach(async () => { + driver = createMemoryDriver(); + session = new WebdavSession(driver); + await driver.mkdir("/dir"); + const handle = await driver.open("/dir/file.txt", "w"); + await handle.write(Buffer.from("hello world", "utf8"), 0, 11, 0); + await handle.close(); +}); + +// --------------------------------------------------------------------------- +// OPTIONS +// --------------------------------------------------------------------------- + +describe("OPTIONS", () => { + it("advertises classes 1, 2 and 3, each of which is answered", async () => { + const reply = await request(session, "OPTIONS", "/"); + expect(reply.status).toBe(200); + expect(reply.headers["dav"]).toBe("1, 2, 3"); + expect(reply.headers["allow"]).toContain("PROPFIND"); + expect(reply.headers["allow"]).toContain("LOCK"); + expect(reply.headers["allow"]).toContain("UNLOCK"); + expect(reply.headers["ms-author-via"]).toBe("DAV"); + }); + + it("answers for a resource that does not exist yet", async () => { + /* The request a client makes before its first `PUT`. A `404` here would end + the conversation before it started. */ + expect((await request(session, "OPTIONS", "/nothing/here")).status).toBe(200); + }); +}); + +describe("an unimplemented method", () => { + it("is 405 with an Allow that tells the truth", async () => { + for (const method of ["PATCH", "REPORT", "SEARCH", "BREW"]) { + const reply = await request(session, method, "/dir/file.txt"); + expect(reply.status, method).toBe(405); + expect(reply.headers["allow"], method).toContain("PROPFIND"); + } + }); +}); + +// --------------------------------------------------------------------------- +// GET / HEAD +// --------------------------------------------------------------------------- + +describe("GET", () => { + it("serves a resource with its validators", async () => { + const reply = await request(session, "GET", "/dir/file.txt"); + expect(reply.status).toBe(200); + expect(reply.text).toBe("hello world"); + expect(reply.headers["content-length"]).toBe("11"); + expect(reply.headers["content-type"]).toBe("application/octet-stream"); + expect(reply.headers["accept-ranges"]).toBe("bytes"); + expect(reply.headers["etag"]).toMatch(/^"[\da-f]{32}"$/); + expect(reply.headers["last-modified"]).toMatch(/GMT$/); + }); + + it("answers a byte range with 206 and a Content-Range", async () => { + const reply = await request(session, "GET", "/dir/file.txt", { + headers: { range: "bytes=6-10" }, + }); + expect(reply.status).toBe(206); + expect(reply.text).toBe("world"); + expect(reply.headers["content-range"]).toBe("bytes 6-10/11"); + expect(reply.headers["content-length"]).toBe("5"); + }); + + it("answers 416 with an unsatisfied-range for a range past the end", async () => { + const reply = await request(session, "GET", "/dir/file.txt", { + headers: { range: "bytes=99-" }, + }); + expect(reply.status).toBe(416); + expect(reply.headers["content-range"]).toBe("bytes */11"); + }); + + it("ignores a range it cannot use, which RFC 9110 §14.2 requires", async () => { + const reply = await request(session, "GET", "/dir/file.txt", { + headers: { range: "furlongs=1-2" }, + }); + expect(reply.status).toBe(200); + expect(reply.text).toBe("hello world"); + }); + + it("is 405 on a collection, with PROPFIND named in Allow", async () => { + const reply = await request(session, "GET", "/dir"); + expect(reply.status).toBe(405); + expect(reply.headers["allow"]).toContain("PROPFIND"); + }); + + it("is 403 on a resource HTTP cannot transfer", async () => { + await driver.mountx.mknod("/fifo", S_IFIFO | 0o644, 0); + expect((await request(session, "GET", "/fifo")).status).toBe(403); + }); + + it("is 404 for a resource that is not there", async () => { + expect((await request(session, "GET", "/dir/missing")).status).toBe(404); + expect((await request(session, "GET", "/dir/file.txt/under")).status).toBe(404); + }); + + it("serves an empty resource as an empty body", async () => { + await (await driver.open("/empty", "w")).close(); + const reply = await request(session, "GET", "/empty"); + expect(reply.status).toBe(200); + expect(reply.headers["content-length"]).toBe("0"); + expect(reply.text).toBe(""); + }); +}); + +describe("HEAD", () => { + it("carries the GET's headers and none of its bytes", async () => { + const get = await request(session, "GET", "/dir/file.txt"); + const head = await request(session, "HEAD", "/dir/file.txt"); + expect(head.status).toBe(200); + expect(head.text).toBe(""); + expect(head.headers).toEqual(get.headers); + }); +}); + +// --------------------------------------------------------------------------- +// PUT +// --------------------------------------------------------------------------- + +describe("PUT", () => { + it("creates with 201 and replaces with 204", async () => { + const created = await request(session, "PUT", "/dir/new.txt", { body: "first" }); + expect(created.status).toBe(201); + expect(created.headers["etag"]).toMatch(/^"[\da-f]{32}"$/); + const replaced = await request(session, "PUT", "/dir/new.txt", { body: "second" }); + expect(replaced.status).toBe(204); + expect((await request(session, "GET", "/dir/new.txt")).text).toBe("second"); + }); + + it("truncates what it replaces", async () => { + await request(session, "PUT", "/dir/file.txt", { body: "hi" }); + expect((await request(session, "GET", "/dir/file.txt")).text).toBe("hi"); + }); + + it("stores an empty body as an empty resource", async () => { + expect((await request(session, "PUT", "/dir/empty", { body: "" })).status).toBe(201); + expect((await request(session, "GET", "/dir/empty")).headers["content-length"]).toBe("0"); + }); + + it("is 409 under a parent that is missing or is not a collection", async () => { + /* §9.7.1's rule, and the one place this differs most visibly from a plain + HTTP server: it is a Conflict, not a Not Found, and the collection is the + client's to create with MKCOL. */ + expect((await request(session, "PUT", "/nope/x.txt", { body: "x" })).status).toBe(409); + expect((await request(session, "PUT", "/dir/file.txt/x", { body: "x" })).status).toBe(409); + await expect(driver.stat("/nope")).rejects.toThrow(); + }); + + it("is 405 onto an existing collection or the root", async () => { + expect((await request(session, "PUT", "/dir", { body: "x" })).status).toBe(405); + expect((await request(session, "PUT", "/", { body: "x" })).status).toBe(405); + }); + + it("refuses a Content-Range rather than writing the whole resource", async () => { + const reply = await request(session, "PUT", "/dir/file.txt", { + headers: { "content-range": "bytes 0-1/11" }, + body: "XX", + }); + expect(reply.status).toBe(400); + expect((await request(session, "GET", "/dir/file.txt")).text).toBe("hello world"); + }); + + it("refuses a body past maxBodyBytes", async () => { + const bounded = new WebdavSession(driver, { maxBodyBytes: 4 }); + expect((await request(bounded, "PUT", "/dir/big", { body: "0123456789" })).status).toBe(413); + }); +}); + +// --------------------------------------------------------------------------- +// DELETE +// --------------------------------------------------------------------------- + +describe("DELETE", () => { + it("removes a resource with 204 and no body", async () => { + const reply = await request(session, "DELETE", "/dir/file.txt"); + expect(reply.status).toBe(204); + expect(reply.text).toBe(""); + expect((await request(session, "GET", "/dir/file.txt")).status).toBe(404); + }); + + it("removes a collection and everything under it", async () => { + await driver.mkdir("/dir/deep"); + await (await driver.open("/dir/deep/leaf", "w")).close(); + expect((await request(session, "DELETE", "/dir")).status).toBe(204); + await expect(driver.stat("/dir")).rejects.toThrow(); + }); + + it("is 400 for Depth: 0 on a collection, which §9.6.1 has no meaning for", async () => { + expect((await request(session, "DELETE", "/dir", { headers: { depth: "0" } })).status).toBe( + 400, + ); + expect((await request(session, "DELETE", "/dir", { headers: { depth: "2" } })).status).toBe( + 400, + ); + }); + + it("removes a link to a collection, not what it points at", async () => { + /* The destructive difference between `stat` and `lstat`: following the link + here would empty out `/dir` and leave the client's own entry behind. */ + await driver.symlink("/dir", "/shortcut"); + expect((await request(session, "DELETE", "/shortcut")).status).toBe(204); + await expect(driver.stat("/shortcut")).rejects.toThrow(); + expect((await driver.stat("/dir/file.txt")).isFile()).toBe(true); + }); + + it("unlinks a link inside a tree rather than walking into it", async () => { + await driver.mkdir("/outside"); + await (await driver.open("/outside/survivor.txt", "w")).close(); + await driver.symlink("/outside", "/dir/shortcut"); + expect((await request(session, "DELETE", "/dir")).status).toBe(204); + expect((await driver.stat("/outside/survivor.txt")).isFile()).toBe(true); + }); + + it("falls back to stat for a driver with no lstat", async () => { + /* `lstat` is optional on `FsDriver`, and a driver without one has no links + for the distinction to matter to — so the delete still works. */ + const { lstat: _lstat, ...withoutLstat } = driver as unknown as Record; + const plain = new WebdavSession(withoutLstat as unknown as FsDriver); + expect((await request(plain, "DELETE", "/dir")).status).toBe(204); + await expect(driver.stat("/dir")).rejects.toThrow(); + }); + + it("is 404 for what is not there and 403 for the share itself", async () => { + expect((await request(session, "DELETE", "/missing")).status).toBe(404); + expect((await request(session, "DELETE", "/")).status).toBe(403); + }); + + it("answers 207 naming what would not go, and leaves the rest gone", async () => { + await driver.mkdir("/dir/keep"); + await (await driver.open("/dir/keep/stuck", "w")).close(); + await (await driver.open("/dir/gone", "w")).close(); + const stubborn = withFailure(driver, "unlink", (path) => + path === "/dir/keep/stuck" ? fsError("EACCES", { syscall: "unlink", path }) : undefined, + ); + const reply = await request(new WebdavSession(stubborn), "DELETE", "/dir"); + expect(reply.status).toBe(207); + expect(hrefs(reply.text)).toEqual(["/dir/keep/stuck"]); + expect(statuses(reply.text)).toEqual([403]); + // What could go, went; the collections above the survivor stayed. + await expect(driver.stat("/dir/gone")).rejects.toThrow(); + await expect(driver.stat("/dir/keep/stuck")).resolves.toBeDefined(); + }); + + it("reports a collection that would not go, and one it could not read", async () => { + await driver.mkdir("/dir/inner"); + const stubbornRmdir = withFailure(driver, "rmdir", (path) => + path === "/dir/inner" ? fsError("EBUSY", { syscall: "rmdir", path }) : undefined, + ); + const busy = await request(new WebdavSession(stubbornRmdir), "DELETE", "/dir"); + expect(busy.status).toBe(207); + expect(hrefs(busy.text)).toEqual(["/dir/inner/"]); + expect(statuses(busy.text)).toEqual([409]); + + const unreadable = withFailure(driver, "readdir", (path) => + path === "/dir" ? fsError("EACCES", { syscall: "scandir", path }) : undefined, + ); + const blind = await request(new WebdavSession(unreadable), "DELETE", "/dir"); + expect(blind.status).toBe(207); + expect(hrefs(blind.text)).toEqual(["/dir/"]); + expect(statuses(blind.text)).toEqual([403]); + }); +}); + +// --------------------------------------------------------------------------- +// MKCOL +// --------------------------------------------------------------------------- + +describe("MKCOL", () => { + it("creates a collection with 201", async () => { + expect((await request(session, "MKCOL", "/fresh")).status).toBe(201); + expect((await driver.stat("/fresh")).isDirectory()).toBe(true); + }); + + it("is 405 on anything that already exists, the root included", async () => { + expect((await request(session, "MKCOL", "/dir")).status).toBe(405); + expect((await request(session, "MKCOL", "/dir/file.txt")).status).toBe(405); + expect((await request(session, "MKCOL", "/")).status).toBe(405); + }); + + it("is 409 under a parent that is not a collection", async () => { + expect((await request(session, "MKCOL", "/nope/deep")).status).toBe(409); + expect((await request(session, "MKCOL", "/dir/file.txt/deep")).status).toBe(409); + }); + + it("is 415 for a request body, which this server defines none of", async () => { + const reply = await request(session, "MKCOL", "/fresh", { body: "" }); + expect(reply.status).toBe(415); + await expect(driver.stat("/fresh")).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// COPY and MOVE +// --------------------------------------------------------------------------- + +describe("COPY", () => { + const to = (destination: string, extra: Record = {}) => ({ + headers: { destination, host: "dav.test", ...extra }, + }); + + it("copies a resource's bytes with 201", async () => { + expect((await request(session, "COPY", "/dir/file.txt", to("/copy.txt"))).status).toBe(201); + expect((await request(session, "GET", "/copy.txt")).text).toBe("hello world"); + // The source is untouched. + expect((await request(session, "GET", "/dir/file.txt")).text).toBe("hello world"); + }); + + it("copies a whole tree by default", async () => { + await driver.mkdir("/dir/deep"); + await (await driver.open("/dir/deep/leaf", "w")).close(); + expect((await request(session, "COPY", "/dir", to("/clone"))).status).toBe(201); + expect((await driver.stat("/clone/deep/leaf")).isFile()).toBe(true); + }); + + it("copies a collection without its members at Depth: 0, per §9.8.3", async () => { + expect((await request(session, "COPY", "/dir", to("/shallow", { depth: "0" }))).status).toBe( + 201, + ); + expect(await driver.readdir("/shallow", { withFileTypes: true })).toEqual([]); + }); + + it("is 400 for Depth: 1, which COPY does not define", async () => { + expect((await request(session, "COPY", "/dir", to("/x", { depth: "1" }))).status).toBe(400); + }); + + it("replaces an existing destination with 204, having deleted it first", async () => { + await driver.mkdir("/target"); + await (await driver.open("/target/stale", "w")).close(); + expect((await request(session, "COPY", "/dir", to("/target"))).status).toBe(204); + /* §9.8.4: the destination is DELETEd with Depth infinity first, so nothing + of what used to be there survives the copy. */ + await expect(driver.stat("/target/stale")).rejects.toThrow(); + expect((await driver.stat("/target/file.txt")).isFile()).toBe(true); + }); + + it("is 412 when the destination exists and Overwrite is F", async () => { + await (await driver.open("/taken", "w")).close(); + const reply = await request(session, "COPY", "/dir/file.txt", to("/taken", { overwrite: "F" })); + expect(reply.status).toBe(412); + expect((await driver.stat("/taken")).size).toBe(0); + }); + + it("is 403 for a destination that is the source or inside it", async () => { + expect((await request(session, "COPY", "/dir", to("/dir"))).status).toBe(403); + expect((await request(session, "COPY", "/dir", to("/dir/inner"))).status).toBe(403); + // The share's own root is neither copied nor moved away. + expect((await request(session, "COPY", "/", to("/backup"))).status).toBe(403); + }); + + it("is 400 for an Overwrite that is neither T nor F", async () => { + expect( + (await request(session, "COPY", "/dir/file.txt", to("/x", { overwrite: "maybe" }))).status, + ).toBe(400); + }); + + it("names a resource it cannot transfer rather than copying nothing quietly", async () => { + await driver.mountx.mknod("/dir/fifo", S_IFIFO | 0o644, 0); + const reply = await request(session, "COPY", "/dir", to("/clone")); + expect(reply.status).toBe(207); + expect(hrefs(reply.text)).toEqual(["/dir/fifo"]); + expect(statuses(reply.text)).toEqual([403]); + // Everything that could be copied still was. + expect((await driver.stat("/clone/file.txt")).isFile()).toBe(true); + }); + + it("reports a link to a collection instead of walking into it", async () => { + /* The walk that would not terminate: `/dir/back` points at its own parent, + so following it would copy `/dir` into `/clone/back`, then into + `/clone/back/back`, for as long as names can get longer. */ + await driver.symlink("/dir", "/dir/back"); + const reply = await request(session, "COPY", "/dir", to("/clone")); + expect(reply.status).toBe(207); + expect(hrefs(reply.text)).toEqual(["/dir/back/"]); + expect(statuses(reply.text)).toEqual([403]); + // Everything that is not the link was copied, once. + expect((await driver.stat("/clone/file.txt")).isFile()).toBe(true); + await expect(driver.stat("/clone/back")).rejects.toThrow(); + }); + + it("copies the bytes a link to a resource points at", async () => { + await driver.symlink("/dir/file.txt", "/dir/alias.txt"); + expect((await request(session, "COPY", "/dir", to("/clone"))).status).toBe(201); + expect((await request(session, "GET", "/clone/alias.txt")).text).toBe("hello world"); + // A copy, not a link: WebDAV has no way to name one. + expect((await driver.lstat("/clone/alias.txt")).isSymbolicLink()).toBe(false); + }); + + it("answers 207 when the destination it must delete first will not go", async () => { + await (await driver.open("/taken", "w")).close(); + const stubborn = withFailure(driver, "unlink", (path) => + path === "/taken" ? fsError("EPERM", { syscall: "unlink", path }) : undefined, + ); + const reply = await request(new WebdavSession(stubborn), "COPY", "/dir/file.txt", to("/taken")); + expect(reply.status).toBe(207); + expect(hrefs(reply.text)).toEqual(["/taken"]); + expect(statuses(reply.text)).toEqual([403]); + expect((await driver.stat("/taken")).size).toBe(0); + }); + + it("is 409 under a destination parent that is not a collection", async () => { + expect((await request(session, "COPY", "/dir/file.txt", to("/nope/x"))).status).toBe(409); + }); + + it("answers 207 for a tree that only partly copied", async () => { + await driver.mkdir("/dir/deep"); + await (await driver.open("/dir/deep/leaf", "w")).close(); + const stubborn = withFailure(driver, "mkdir", (path) => + path === "/clone/deep" ? fsError("ENOSPC", { syscall: "mkdir", path }) : undefined, + ); + const reply = await request(new WebdavSession(stubborn), "COPY", "/dir", to("/clone")); + expect(reply.status).toBe(207); + expect(hrefs(reply.text)).toEqual(["/dir/deep/"]); + expect(statuses(reply.text)).toEqual([507]); + // What could be copied, was. + expect((await driver.stat("/clone/file.txt")).isFile()).toBe(true); + }); +}); + +describe("COPY of a symbolic link at the top of the tree", () => { + it("is 403 for a link to a collection, which is what makes the walk terminate", async () => { + /* The destination-inside-source test compares paths as text, so a link to + an ancestor slips past it — and the walk then copies a tree it is writing + into, creating the next level every pass. `#copyTree` already refuses a + link to a collection it meets *inside* the tree; this is the same rule at + the top of it. */ + await driver.symlink("/", "/link"); + const reply = await request(session, "COPY", "/link", { + headers: { destination: "/dst" }, + }); + expect(reply.status).toBe(403); + await expect(driver.stat("/dst")).rejects.toThrow(); + }); + + it("still copies the bytes behind a link to a file, and still moves the link", async () => { + await driver.symlink("/dir/file.txt", "/pointer"); + expect( + (await request(session, "COPY", "/pointer", { headers: { destination: "/copied.txt" } })) + .status, + ).toBe(201); + expect((await request(session, "GET", "/copied.txt")).text).toBe("hello world"); + // A MOVE walks nothing — it renames the link itself — so it is untouched. + await driver.symlink("/dir", "/dirlink"); + expect( + (await request(session, "MOVE", "/dirlink", { headers: { destination: "/moved-link" } })) + .status, + ).toBe(201); + expect((await driver.lstat("/moved-link")).isSymbolicLink()).toBe(true); + }); +}); + +describe("MOVE", () => { + const to = (destination: string, extra: Record = {}) => ({ + headers: { destination, host: "dav.test", ...extra }, + }); + + it("renames with 201 and leaves nothing behind", async () => { + expect((await request(session, "MOVE", "/dir/file.txt", to("/moved.txt"))).status).toBe(201); + expect((await request(session, "GET", "/moved.txt")).text).toBe("hello world"); + expect((await request(session, "GET", "/dir/file.txt")).status).toBe(404); + }); + + it("takes an absolute-URI destination on the same origin", async () => { + const reply = await request(session, "MOVE", "/dir/file.txt", { + headers: { destination: "http://dav.test/moved.txt", host: "dav.test" }, + }); + expect(reply.status).toBe(201); + }); + + it("is 502 for a destination on another server", async () => { + const reply = await request(session, "MOVE", "/dir/file.txt", { + headers: { destination: "http://elsewhere/moved.txt", host: "dav.test" }, + }); + expect(reply.status).toBe(502); + }); + + it("replaces an existing destination with 204", async () => { + await (await driver.open("/taken", "w")).close(); + expect((await request(session, "MOVE", "/dir/file.txt", to("/taken"))).status).toBe(204); + expect((await request(session, "GET", "/taken")).text).toBe("hello world"); + }); + + it("is 400 at any depth but infinity, per §9.9.2", async () => { + expect((await request(session, "MOVE", "/dir", to("/x", { depth: "0" }))).status).toBe(400); + expect((await request(session, "MOVE", "/dir", to("/x", { depth: "1" }))).status).toBe(400); + }); + + it("is 403 for the share itself", async () => { + expect((await request(session, "MOVE", "/", to("/elsewhere"))).status).toBe(403); + }); + + it("is 501 when the driver cannot rename", async () => { + /* `ENOSYS` is what the loopback answers for a method a driver does not + have, and 501 is the answer that says so without blaming the client. */ + const { rename: _rename, ...withoutRename } = driver as unknown as Record; + const reply = await request( + new WebdavSession(withoutRename as unknown as FsDriver), + "MOVE", + "/dir/file.txt", + to("/moved.txt"), + ); + expect(reply.status).toBe(501); + }); +}); + +// --------------------------------------------------------------------------- +// PROPFIND +// --------------------------------------------------------------------------- + +describe("PROPFIND", () => { + it("describes the resource itself at Depth: 0", async () => { + const reply = await request(session, "PROPFIND", "/dir/file.txt", { + headers: { depth: "0" }, + }); + expect(reply.status).toBe(207); + expect(reply.headers["content-type"]).toBe('application/xml; charset="utf-8"'); + expect(hrefs(reply.text)).toEqual(["/dir/file.txt"]); + expect(property(reply.text, "getcontentlength")).toBe("11"); + expect(property(reply.text, "displayname")).toBe("file.txt"); + expect(property(reply.text, "getcontenttype")).toBe("application/octet-stream"); + expect(reply.text).toContain(""); + expect(statuses(reply.text)).toEqual([200]); + }); + + it("lists a collection and its children at Depth: 1", async () => { + await driver.mkdir("/dir/sub"); + const reply = await request(session, "PROPFIND", "/dir", { headers: { depth: "1" } }); + expect(reply.status).toBe(207); + /* The collection itself first, then its members; a collection's href ends + in a slash and a resource's does not. */ + expect(hrefs(reply.text)).toEqual(["/dir/", "/dir/file.txt", "/dir/sub/"]); + expect(reply.text).toContain(""); + }); + + it("percent-encodes a name that would otherwise re-parse as syntax", async () => { + await (await driver.open("/dir/a b?c", "w")).close(); + const reply = await request(session, "PROPFIND", "/dir", { headers: { depth: "1" } }); + expect(hrefs(reply.text)).toContain("/dir/a%20b%3Fc"); + }); + + it("refuses infinity — the default — with propfind-finite-depth", async () => { + for (const headers of [{}, { depth: "infinity" }] as Record[]) { + const reply = await request(session, "PROPFIND", "/dir", { headers }); + expect(reply.status).toBe(403); + expect(reply.text).toContain(""); + } + }); + + it("is 400 for a Depth the protocol has no meaning for", async () => { + expect((await request(session, "PROPFIND", "/dir", { headers: { depth: "2" } })).status).toBe( + 400, + ); + }); + + it("leaves a property the resource does not have out of allprop", async () => { + /* A collection has no `getcontentlength` — §15.4 defines it as the + `Content-Length` of a `GET`, which is a 405 here — so allprop does not + mention it, and there is no 404 propstat at all. */ + const reply = await request(session, "PROPFIND", "/dir", { headers: { depth: "0" } }); + expect(reply.text).not.toContain("getcontentlength"); + expect(reply.text).not.toContain("getetag"); + expect(statuses(reply.text)).toEqual([200]); + }); + + it("splits found and missing properties into two propstats", async () => { + const reply = await request(session, "PROPFIND", "/dir/file.txt", { + headers: { depth: "0" }, + body: + `` + + `` + + ``, + }); + expect(statuses(reply.text)).toEqual([200, 404]); + expect(property(reply.text, "getcontentlength")).toBe("11"); + expect(reply.text).toContain(""); + }); + + it("answers propname with names and no values", async () => { + const reply = await request(session, "PROPFIND", "/dir/file.txt", { + headers: { depth: "0" }, + body: ``, + }); + expect(reply.text).toContain(""); + expect(reply.text).toContain(""); + expect(statuses(reply.text)).toEqual([200]); + }); + + it("answers the quota pair from statfs, and only when asked", async () => { + const body = + `` + + `` + + ``; + const reply = await request(session, "PROPFIND", "/", { headers: { depth: "0" }, body }); + expect(statuses(reply.text)).toEqual([200]); + expect(Number(property(reply.text, "quota-available-bytes"))).toBeGreaterThan(0); + expect(Number(property(reply.text, "quota-used-bytes"))).toBeGreaterThanOrEqual(0); + // Not in `allprop`, which RFC 4331 §3 requires. + const all = await request(session, "PROPFIND", "/", { headers: { depth: "0" } }); + expect(all.text).not.toContain("quota-"); + }); + + it("is a 404 propstat, never a zero, for a driver with no statfs", async () => { + const { statfs: _statfs, ...withoutStatfs } = driver as unknown as Record; + const reply = await request( + new WebdavSession(withoutStatfs as unknown as FsDriver), + "PROPFIND", + "/", + { + headers: { depth: "0" }, + body: ``, + }, + ); + expect(statuses(reply.text)).toEqual([404]); + }); + + it("skips a child that vanishes between the listing and its stat", async () => { + await (await driver.open("/dir/ghost", "w")).close(); + const haunted = withFailure(driver, "stat", (path) => + path === "/dir/ghost" ? fsError("ENOENT", { syscall: "stat", path }) : undefined, + ); + const reply = await request(new WebdavSession(haunted), "PROPFIND", "/dir", { + headers: { depth: "1" }, + }); + expect(reply.status).toBe(207); + expect(hrefs(reply.text)).toEqual(["/dir/", "/dir/file.txt"]); + }); + + it("is 404 for a resource that is not there", async () => { + expect((await request(session, "PROPFIND", "/nope", { headers: { depth: "0" } })).status).toBe( + 404, + ); + }); + + it("is 400 for a body that is not a propfind", async () => { + const reply = await request(session, "PROPFIND", "/dir", { + headers: { depth: "0" }, + body: ``, + }); + expect(reply.status).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// PROPPATCH +// --------------------------------------------------------------------------- + +describe("PROPPATCH", () => { + it("names every property with its own status and the condition that explains it", async () => { + const reply = await request(session, "PROPPATCH", "/dir/file.txt", { + body: + `` + + `x` + + `` + + ``, + }); + expect(reply.status).toBe(207); + expect(statuses(reply.text)).toEqual([403]); + expect(reply.text).toContain(""); + expect(reply.text).toContain(""); + expect(reply.text).toContain(""); + }); + + it("is 404 for a resource that is not there", async () => { + const reply = await request(session, "PROPPATCH", "/nope", { + body: ``, + }); + expect(reply.status).toBe(404); + }); + + it("stores getlastmodified through utimes, which is the one property it can", async () => { + const when = "Sun, 06 Nov 1994 08:49:37 GMT"; + const reply = await request(session, "PROPPATCH", "/dir/file.txt", { + body: + `` + + `${when}` + + ``, + }); + expect(reply.status).toBe(207); + expect(statuses(reply.text)).toEqual([200]); + expect(reply.text).not.toContain("cannot-modify-protected-property"); + // The driver really has it, and a GET reports it back. + expect((await driver.stat("/dir/file.txt")).mtimeMs).toBe(Date.parse(when)); + expect((await request(session, "HEAD", "/dir/file.txt")).headers["last-modified"]).toBe(when); + }); + + it("is 409 for a value that is not an HTTP-date (§9.2.1)", async () => { + const reply = await request(session, "PROPPATCH", "/dir/file.txt", { + body: + `` + + `last Tuesday` + + ``, + }); + expect(statuses(reply.text)).toEqual([409]); + expect((await driver.stat("/dir/file.txt")).mtimeMs).not.toBe(Number.NaN); + }); + + it("is atomic: one failure makes the settable property 424 and writes nothing", async () => { + /* §9.2: "instructions MUST either all be executed or none executed", and + §9.2.1 spells out the reply — a property that would have succeeded + becomes 424 Failed Dependency. */ + const before = (await driver.stat("/dir/file.txt")).mtimeMs; + const reply = await request(session, "PROPPATCH", "/dir/file.txt", { + body: + `` + + `` + + `Sun, 06 Nov 1994 08:49:37 GMT` + + `"whatever"` + + `` + + ``, + }); + expect(statuses(reply.text)).toEqual([424, 403]); + expect((await driver.stat("/dir/file.txt")).mtimeMs).toBe(before); + }); + + it("refuses getlastmodified on a driver that cannot keep it", async () => { + /* Declared-or-inferred, never faked: without `times` the property really is + protected here, and answering 200 would be storing nothing. */ + const { utimes: _utimes, ...timeless } = driver as FsDriver & { utimes?: unknown }; + const reply = await request( + new WebdavSession(timeless as FsDriver), + "PROPPATCH", + "/dir/file.txt", + { + body: + `` + + `Sun, 06 Nov 1994 08:49:37 GMT` + + ``, + }, + ); + expect(statuses(reply.text)).toEqual([403]); + expect(reply.text).toContain(""); + }); + + it("removes nothing: a live property cannot be removed either", async () => { + const reply = await request(session, "PROPPATCH", "/dir/file.txt", { + body: + `` + + `` + + ``, + }); + expect(statuses(reply.text)).toEqual([403]); + }); +}); + +// --------------------------------------------------------------------------- +// LOCK / UNLOCK +// --------------------------------------------------------------------------- + +/* One base moment and a clock the test moves by hand: a lease is the one thing + in this protocol that passes on its own, and waiting for it is not a test. */ +const LOCK_START = 1_700_000_000_000; + +/** A session with a clock a test drives and tokens a test can write down. */ +function lockingSession(base: FsDriver = driver): { + session: WebdavSession; + advance: (seconds: number) => void; +} { + let clock = LOCK_START; + let minted = 0; + const built = new WebdavSession(base, { + now: () => clock, + locks: { newToken: () => `urn:uuid:token-${++minted}` }, + }); + return { session: built, advance: (seconds) => (clock += seconds * 1000) }; +} + +const LOCKINFO = + `` + + `` + + `` + + `mailto:ada@example.com` + + ``; + +const SHARED_LOCKINFO = + `` + + `` + + `` + + ``; + +/** `LOCK` a path and answer the token the reply minted. */ +async function lockOf( + target: WebdavSession, + path: string, + options: { headers?: Record; body?: string } = {}, +): Promise { + const reply = await request(target, "LOCK", path, { + headers: options.headers, + body: options.body ?? LOCKINFO, + }); + const token = /([^<]+)<\/href>/.exec(reply.text)?.[1]; + if (token === undefined) { + throw new Error(`LOCK ${path} answered ${reply.status} and no token`); + } + return token; +} + +describe("LOCK", () => { + it("answers 200, a Lock-Token header and §9.10.1's body for a resource that is there", async () => { + const { session: locking } = lockingSession(); + const reply = await request(locking, "LOCK", "/dir/file.txt", { body: LOCKINFO }); + expect(reply.status).toBe(200); + expect(reply.headers["lock-token"]).toBe(""); + expect(reply.headers["content-type"]).toBe(`application/xml; charset="utf-8"`); + expect(reply.text).toContain(``); + expect(reply.text).toContain(``); + expect(reply.text).toContain(`infinity`); + expect(reply.text).toContain(`mailto:ada@example.com`); + expect(reply.text).toContain(`Second-600`); + expect(reply.text).toContain(`urn:uuid:token-1`); + expect(reply.text).toContain(`/dir/file.txt`); + expect(locking.locks.all(LOCK_START)).toHaveLength(1); + }); + + it("creates the locked empty resource §7.3 requires, and answers 201", async () => { + const { session: locking } = lockingSession(); + const reply = await request(locking, "LOCK", "/dir/reserved.txt", { body: LOCKINFO }); + expect(reply.status).toBe(201); + expect(reply.headers["lock-token"]).toBe(""); + // A real, empty, readable resource — not a lock-null one. + expect((await driver.stat("/dir/reserved.txt")).size).toBe(0); + expect((await request(locking, "GET", "/dir/reserved.txt")).status).toBe(200); + }); + + it("is 409 when the collection above the new resource does not exist", async () => { + /* §9.10.6: "a resource cannot be created at the destination until one or + more intermediate collections have been created. The server MUST NOT + create those intermediate collections automatically." */ + const { session: locking } = lockingSession(); + expect((await request(locking, "LOCK", "/nope/deep.txt", { body: LOCKINFO })).status).toBe(409); + await expect(driver.stat("/nope")).rejects.toThrow(); + }); + + it("takes Depth 0 or infinity and refuses 1 (§9.10.3)", async () => { + const { session: locking } = lockingSession(); + const zero = await request(locking, "LOCK", "/dir", { + headers: { depth: "0" }, + body: LOCKINFO, + }); + expect(zero.status).toBe(200); + expect(zero.text).toContain("0"); + expect(zero.text).toContain(`/dir/`); + const one = await request(locking, "LOCK", "/dir/file.txt", { + headers: { depth: "1" }, + body: LOCKINFO, + }); + expect(one.status).toBe(400); + }); + + it("honours the Timeout header, and answers Infinite with the cap it will grant", async () => { + const { session: locking } = lockingSession(); + const asked = await request(locking, "LOCK", "/dir/file.txt", { + headers: { timeout: "Second-90" }, + body: LOCKINFO, + }); + expect(asked.text).toContain("Second-90"); + const forever = await request(locking, "LOCK", "/dir", { + headers: { timeout: "Infinite, Second-4100000000", depth: "0" }, + body: SHARED_LOCKINFO, + }); + expect(forever.text).toContain("Second-3600"); + }); + + it("is 423 no-conflicting-lock, naming the lock in the way (§9.10.5, §16)", async () => { + const { session: locking } = lockingSession(); + await lockOf(locking, "/dir", { headers: { depth: "infinity" } }); + const reply = await request(locking, "LOCK", "/dir/file.txt", { body: LOCKINFO }); + expect(reply.status).toBe(423); + expect(reply.text).toContain(`/dir/`); + }); + + it("refuses a depth-infinity lock over a subtree that already holds one (§7.4)", async () => { + const { session: locking } = lockingSession(); + await lockOf(locking, "/dir/file.txt"); + const reply = await request(locking, "LOCK", "/dir", { body: LOCKINFO }); + expect(reply.status).toBe(423); + expect(reply.text).toContain(`/dir/file.txt`); + }); + + it("grants two shared locks on one resource", async () => { + const { session: locking } = lockingSession(); + const first = await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); + const second = await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); + expect(second).not.toBe(first); + expect(locking.locks.covering("/dir/file.txt", LOCK_START)).toHaveLength(2); + }); + + it("is 503 rather than a new lock when the table is full", async () => { + const full = new WebdavSession(driver, { now: () => LOCK_START, locks: { maxLocks: 1 } }); + expect((await request(full, "LOCK", "/dir/file.txt", { body: SHARED_LOCKINFO })).status).toBe( + 200, + ); + const reply = await request(full, "LOCK", "/dir", { + headers: { depth: "0" }, + body: SHARED_LOCKINFO, + }); + expect(reply.status).toBe(503); + }); + + it("refuses a body that is not a lockinfo this server can serve", async () => { + const { session: locking } = lockingSession(); + for (const body of [ + ``, + ``, + ``, + ]) { + expect((await request(locking, "LOCK", "/dir/file.txt", { body })).status).toBe(400); + } + }); + + it("lapses on its own once the lease runs out, with no timer anywhere", async () => { + const { session: locking, advance } = lockingSession(); + await lockOf(locking, "/dir/file.txt", { headers: { timeout: "Second-60" } }); + advance(59); + expect(locking.locks.all(LOCK_START + 59_000)).toHaveLength(1); + advance(1); + // And the resource takes an exclusive lock again, which is what expiry is for. + expect((await request(locking, "LOCK", "/dir/file.txt", { body: LOCKINFO })).status).toBe(200); + }); +}); + +describe("LOCK refresh", () => { + it("restarts the lease and answers no Lock-Token (§9.10.2)", async () => { + const { session: locking, advance } = lockingSession(); + const token = await lockOf(locking, "/dir/file.txt", { headers: { timeout: "Second-600" } }); + advance(300); + const reply = await request(locking, "LOCK", "/dir/file.txt", { + headers: { if: `(<${token}>)`, timeout: "Second-120", depth: "1" }, + }); + expect(reply.status).toBe(200); + expect(reply.headers["lock-token"]).toBeUndefined(); + expect(reply.text).toContain(`Second-120`); + expect(reply.text).toContain(`${token}`); + // `Depth: 1` above is ignored on a refresh, which §9.10.2 requires. + expect(reply.text).toContain(`infinity`); + expect(locking.locks.all(LOCK_START + 300_000)).toHaveLength(1); + }); + + it("refreshes through any URL inside the lock's scope", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir"); + const reply = await request(locking, "LOCK", "/dir/file.txt", { + headers: { if: `(<${token}>)` }, + }); + expect(reply.status).toBe(200); + expect(reply.text).toContain(`/dir/`); + }); + + it("needs an If header, since that is the only thing naming the lock", async () => { + const { session: locking } = lockingSession(); + await lockOf(locking, "/dir/file.txt"); + expect((await request(locking, "LOCK", "/dir/file.txt")).status).toBe(400); + }); + + it("is 412 lock-token-matches-request-uri for a token that names no lock here", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir/file.txt"); + // A token that never existed. + const unknown = await request(locking, "LOCK", "/dir/file.txt", { + headers: { if: `()` }, + }); + expect(unknown.status).toBe(412); + expect(unknown.text).toContain(""); + // A real token, on a resource outside its depth-0 scope. + const outside = await request(locking, "LOCK", "/dir", { headers: { if: `(<${token}>)` } }); + expect(outside.status).toBe(412); + }); + + it("is 400 for an If header that is not §10.4.2's grammar", async () => { + const { session: locking } = lockingSession(); + expect( + (await request(locking, "LOCK", "/dir/file.txt", { headers: { if: "( { + it("deletes the lock and answers 204 with no body (§9.11.1)", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir/file.txt"); + const reply = await request(locking, "UNLOCK", "/dir/file.txt", { + headers: { "lock-token": `<${token}>` }, + }); + expect(reply.status).toBe(204); + expect(reply.text).toBe(""); + expect(locking.locks.all(LOCK_START)).toEqual([]); + }); + + it("unlocks through any URL inside the scope, and refuses one outside it", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir"); + const outside = await request(locking, "UNLOCK", "/", { + headers: { "lock-token": `<${token}>` }, + }); + expect(outside.status).toBe(409); + expect(outside.text).toContain(""); + expect( + ( + await request(locking, "UNLOCK", "/dir/file.txt", { + headers: { "lock-token": `<${token}>` }, + }) + ).status, + ).toBe(204); + }); + + it("is 400 with no token and 409 with one that names no lock", async () => { + const { session: locking } = lockingSession(); + expect((await request(locking, "UNLOCK", "/dir/file.txt")).status).toBe(400); + expect( + (await request(locking, "UNLOCK", "/dir/file.txt", { headers: { "lock-token": "nope" } })) + .status, + ).toBe(400); + expect( + ( + await request(locking, "UNLOCK", "/dir/file.txt", { + headers: { "lock-token": "" }, + }) + ).status, + ).toBe(409); + }); +}); + +describe("locks and the resources under them", () => { + it("reports supportedlock and lockdiscovery as what is really there (§15.8, §15.10)", async () => { + const { session: locking } = lockingSession(); + const empty = await request(locking, "PROPFIND", "/dir/file.txt", { + headers: { depth: "0" }, + body: ``, + }); + expect(empty.text).toContain(""); + expect(empty.text).toContain(""); + const token = await lockOf(locking, "/dir"); + const held = await request(locking, "PROPFIND", "/dir/file.txt", { + headers: { depth: "0" }, + body: ``, + }); + /* An indirectly locked member reports the ancestor's lock: §15.8 describes + "the active locks on a resource", and this resource is locked. */ + expect(held.text).toContain(`${token}`); + expect(held.text).toContain(`/dir/`); + expect(statuses(held.text)).toEqual([200]); + }); + + it("destroys the locks a DELETE unmaps, and leaves the ones above it (§6.1)", async () => { + const { session: locking } = lockingSession(); + await lockOf(locking, "/", { headers: { depth: "0" }, body: SHARED_LOCKINFO }); + const token = await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); + expect(locking.locks.all(LOCK_START)).toHaveLength(2); + const reply = await request(locking, "DELETE", "/dir/file.txt", { + headers: { if: `(<${token}>)` }, + }); + expect(reply.status).toBe(204); + expect(locking.locks.all(LOCK_START).map((lock) => lock.path)).toEqual(["/"]); + }); + + it("does not move a lock with the resource it locks (§7.6)", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir/file.txt"); + const reply = await request(locking, "MOVE", "/dir/file.txt", { + headers: { destination: "/moved.txt", if: `(<${token}>)` }, + }); + expect(reply.status).toBe(201); + expect(locking.locks.all(LOCK_START)).toEqual([]); + }); + + it("keeps a destination's own lock across an overwriting MOVE (§7.6)", async () => { + /* "If there is an existing lock at the destination, the server MUST add the + moved resource to the destination lock scope" — the lock stays put and + the arriving resource joins it. */ + const { session: locking } = lockingSession(); + await (await driver.open("/dir/other.txt", "w")).close(); + const token = await lockOf(locking, "/dir/other.txt", { body: SHARED_LOCKINFO }); + const reply = await request(locking, "MOVE", "/dir/file.txt", { + /* §7.5.1's own form: the token belongs to the destination, so the list + is tagged with the destination rather than left to fall on the request + URI — which is not locked by it, and would be a 412. */ + headers: { destination: "/dir/other.txt", if: ` (<${token}>)` }, + }); + expect(reply.status).toBe(204); + expect(locking.locks.all(LOCK_START).map((lock) => lock.token)).toEqual([token]); + }); +}); + +describe("the If header", () => { + it("is a precondition on any method, and a false one is 412", async () => { + const { session: locking } = lockingSession(); + const wrong = { if: `(["${"0".repeat(32)}"])` }; + expect((await request(locking, "GET", "/dir/file.txt", { headers: wrong })).status).toBe(412); + expect( + (await request(locking, "PUT", "/dir/file.txt", { headers: wrong, body: "x" })).status, + ).toBe(412); + expect((await request(locking, "DELETE", "/dir/file.txt", { headers: wrong })).status).toBe( + 412, + ); + }); + + it("matches an entity tag, weakly, as §10.4.4 lets a server choose", async () => { + const { session: locking } = lockingSession(); + const etag = (await request(locking, "HEAD", "/dir/file.txt")).headers["etag"] as string; + expect( + (await request(locking, "GET", "/dir/file.txt", { headers: { if: `([${etag}])` } })).status, + ).toBe(200); + /* §10.4.9's example carries `[W/"A weak ETag"]` and expects it to match, so + the weak comparison function is the one in use. */ + expect( + (await request(locking, "GET", "/dir/file.txt", { headers: { if: `([W/${etag}])` } })).status, + ).toBe(200); + }); + + it("is a conjunction inside a list and a disjunction between them (§10.4.3)", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir/file.txt"); + const etag = (await request(locking, "HEAD", "/dir/file.txt")).headers["etag"] as string; + // Both conditions hold. + expect( + ( + await request(locking, "GET", "/dir/file.txt", { + headers: { if: `(<${token}> [${etag}])` }, + }) + ).status, + ).toBe(200); + // One of the two fails, so the list does — and there is no other list. + expect( + (await request(locking, "GET", "/dir/file.txt", { headers: { if: `(<${token}> ["nope"])` } })) + .status, + ).toBe(412); + // A second list saves it. + expect( + ( + await request(locking, "GET", "/dir/file.txt", { + headers: { if: `(<${token}> ["nope"]) ([${etag}])` }, + }) + ).status, + ).toBe(200); + }); + + it("evaluates a tagged list against the resource it names (§10.4.10)", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir"); + /* The lock is on the collection, and the list says so — this is the exact + shape §10.4.10 walks through for a DELETE of a member. */ + const tagged = await request(locking, "DELETE", "/dir/file.txt", { + headers: { if: ` (<${token}>)` }, + }); + expect(tagged.status).toBe(204); + }); + + it("treats an unmapped URL as a resource with none of the state asked about (§10.4.11)", async () => { + const { session: locking } = lockingSession(); + expect( + (await request(locking, "GET", "/dir/file.txt", { headers: { if: ` (["4217"])` } })) + .status, + ).toBe(412); + expect( + ( + await request(locking, "GET", "/dir/file.txt", { + headers: { if: ` (Not ["4217"])` }, + }) + ).status, + ).toBe(200); + }); + + it("submits a token even when the list it is in was never true (§10.4.8)", async () => { + /* `(Not )` is the idiom: it makes the whole header true, so + the first list's truth stops mattering, while the token in it still + counts as submitted. */ + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir/file.txt"); + const reply = await request(locking, "PUT", "/dir/file.txt", { + headers: { if: `(<${token}> ["stale"]) (Not )` }, + body: "rewritten", + }); + expect(reply.status).toBe(204); + }); + + it("is 400 for a header that is not the grammar", async () => { + const { session: locking } = lockingSession(); + expect( + (await request(locking, "GET", "/dir/file.txt", { headers: { if: "nonsense" } })).status, + ).toBe(400); + }); +}); + +describe("what a write lock protects", () => { + it("refuses a PUT to a locked resource, and names the lock root (§7.5.2)", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir/file.txt"); + const refused = await request(locking, "PUT", "/dir/file.txt", { body: "mine" }); + expect(refused.status).toBe(423); + expect(refused.text).toContain( + `/dir/file.txt`, + ); + // The bytes are untouched, and the token is what lets them through. + expect((await request(locking, "GET", "/dir/file.txt")).text).toBe("hello world"); + const allowed = await request(locking, "PUT", "/dir/file.txt", { + headers: { if: `(<${token}>)` }, + body: "mine", + }); + expect(allowed.status).toBe(204); + }); + + it("is §7.5.2's answer for a member of a depth-infinity locked collection", async () => { + const { session: locking } = lockingSession(); + await lockOf(locking, "/dir"); + const reply = await request(locking, "DELETE", "/dir/file.txt"); + expect(reply.status).toBe(423); + expect(reply.text).toContain(`/dir/`); + }); + + it("protects a locked collection's membership at depth 0 as well (§7.4)", async () => { + /* A depth-0 lock on a collection protects nothing inside it *except* its + membership — so an existing member is writable and a new one is not. */ + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir", { headers: { depth: "0" } }); + expect((await request(locking, "PUT", "/dir/file.txt", { body: "still fine" })).status).toBe( + 204, + ); + expect((await request(locking, "PUT", "/dir/new.txt", { body: "no" })).status).toBe(423); + expect((await request(locking, "MKCOL", "/dir/sub")).status).toBe(423); + expect((await request(locking, "DELETE", "/dir/file.txt")).status).toBe(423); + expect( + (await request(locking, "MKCOL", "/dir/sub", { headers: { if: ` (<${token}>)` } })) + .status, + ).toBe(201); + }); + + it("answers 207 with 423 for a locked member of a tree, and deletes nothing", async () => { + /* §9.6.1: "the Multi-Status body could include a response with status 423 + (Locked) if an internal resource was locked" — the failing resource is + not the request URI, so it cannot be a bare status. */ + const { session: locking } = lockingSession(); + await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); + const reply = await request(locking, "DELETE", "/dir"); + expect(reply.status).toBe(207); + expect(hrefs(reply.text)).toEqual(["/dir/file.txt"]); + expect(statuses(reply.text)).toEqual([423]); + expect((await driver.stat("/dir/file.txt")).size).toBe(11); + }); + + it("needs both ends of a MOVE and only the destination of a COPY (§7.5.1)", async () => { + const { session: locking } = lockingSession(); + await driver.mkdir("/target"); + const source = await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); + const target = await lockOf(locking, "/target", { body: SHARED_LOCKINFO }); + // A COPY leaves the source alone, so only the destination's token is owed. + expect( + ( + await request(locking, "COPY", "/dir/file.txt", { + headers: { destination: "/target/copy.txt", if: ` (<${target}>)` }, + }) + ).status, + ).toBe(201); + // A MOVE changes both ends. + expect( + ( + await request(locking, "MOVE", "/dir/file.txt", { + headers: { destination: "/target/moved.txt", if: ` (<${target}>)` }, + }) + ).status, + ).toBe(423); + expect( + ( + await request(locking, "MOVE", "/dir/file.txt", { + headers: { + destination: "/target/moved.txt", + if: `(<${source}>) (<${target}>)`, + }, + }) + ).status, + ).toBe(201); + }); + + it("protects PROPPATCH and leaves GET, HEAD and PROPFIND alone (§7)", async () => { + const { session: locking } = lockingSession(); + await lockOf(locking, "/dir/file.txt"); + const proppatch = await request(locking, "PROPPATCH", "/dir/file.txt", { + body: ``, + }); + expect(proppatch.status).toBe(423); + /* "All other HTTP/WebDAV methods defined so far -- GET in particular -- + function independently of a write lock." */ + expect((await request(locking, "GET", "/dir/file.txt")).status).toBe(200); + expect((await request(locking, "HEAD", "/dir/file.txt")).status).toBe(200); + expect( + (await request(locking, "PROPFIND", "/dir/file.txt", { headers: { depth: "0" } })).status, + ).toBe(207); + }); + + it("refuses a LOCK that would create a member of a locked collection (§7.4)", async () => { + const { session: locking } = lockingSession(); + const token = await lockOf(locking, "/dir", { headers: { depth: "0" } }); + const refused = await request(locking, "LOCK", "/dir/reserved.txt", { body: LOCKINFO }); + expect(refused.status).toBe(423); + await expect(driver.stat("/dir/reserved.txt")).rejects.toThrow(); + const allowed = await request(locking, "LOCK", "/dir/reserved.txt", { + headers: { if: ` (<${token}>)` }, + body: LOCKINFO, + }); + expect(allowed.status).toBe(201); + }); +}); + +// --------------------------------------------------------------------------- +// RFC 9110's conditional requests +// --------------------------------------------------------------------------- + +describe("conditional requests", () => { + /** The resource's validators, as a client would have read them. */ + async function validators(): Promise<{ etag: string; lastModified: string }> { + const reply = await request(session, "HEAD", "/dir/file.txt"); + return { + etag: reply.headers["etag"] as string, + lastModified: reply.headers["last-modified"] as string, + }; + } + + it("answers 304 to a GET whose client already has this representation", async () => { + const { etag, lastModified } = await validators(); + const conditions: Record[] = [ + { "if-none-match": etag }, + { "if-none-match": `"other", ${etag}` }, + { "if-none-match": "*" }, + { "if-modified-since": lastModified }, + ]; + for (const headers of conditions) { + const reply = await request(session, "GET", "/dir/file.txt", { headers }); + expect(reply.status, JSON.stringify(headers)).toBe(304); + // §15.4.5: the validators, and no content — not even a length. + expect(reply.headers["etag"]).toBe(etag); + expect(reply.headers["content-length"]).toBeUndefined(); + expect(reply.text).toBe(""); + } + expect( + (await request(session, "HEAD", "/dir/file.txt", { headers: { "if-none-match": etag } })) + .status, + ).toBe(304); + }); + + it("answers 412 to a GET whose precondition failed", async () => { + const { lastModified } = await validators(); + const past = new Date(Date.parse(lastModified) - 60_000).toUTCString(); + const conditions: Record[] = [ + { "if-match": `"${"9".repeat(32)}"` }, + { "if-match": `W/"weak"` }, + { "if-unmodified-since": past }, + ]; + for (const headers of conditions) { + expect( + (await request(session, "GET", "/dir/file.txt", { headers })).status, + JSON.stringify(headers), + ).toBe(412); + } + }); + + it("evaluates the conditionals before the Range (RFC 9110 §13.2.2)", async () => { + const { etag } = await validators(); + const reply = await request(session, "GET", "/dir/file.txt", { + headers: { "if-none-match": etag, range: "bytes=0-3" }, + }); + expect(reply.status).toBe(304); + }); + + it("ignores a date it cannot parse, which §13.1.3 requires", async () => { + const reply = await request(session, "GET", "/dir/file.txt", { + headers: { "if-modified-since": "the day before yesterday" }, + }); + expect(reply.status).toBe(200); + expect(reply.text).toBe("hello world"); + }); + + it("refuses a PUT whose precondition failed, and leaves the bytes alone", async () => { + const { etag } = await validators(); + const stale = await request(session, "PUT", "/dir/file.txt", { + headers: { "if-match": `"${"9".repeat(32)}"` }, + body: "overwritten", + }); + expect(stale.status).toBe(412); + expect((await request(session, "GET", "/dir/file.txt")).text).toBe("hello world"); + /* A match is 412 on a PUT and never 304: §13.2.2 gives `304` to `GET` and + `HEAD` only. */ + const present = await request(session, "PUT", "/dir/file.txt", { + headers: { "if-none-match": etag }, + body: "overwritten", + }); + expect(present.status).toBe(412); + const fresh = await request(session, "PUT", "/dir/file.txt", { + headers: { "if-match": etag }, + body: "overwritten", + }); + expect(fresh.status).toBe(204); + }); + + it("is where `If-None-Match: *` means create-only", async () => { + /* The one case `mountx/s3` never has to answer: a `PUT` to a URL with no + representation at all. §13.1.1 fails `If-Match` on it and §13.1.2 lets + `If-None-Match` through. */ + expect( + ( + await request(session, "PUT", "/dir/new.txt", { + headers: { "if-none-match": "*" }, + body: "created", + }) + ).status, + ).toBe(201); + expect( + ( + await request(session, "PUT", "/dir/new.txt", { + headers: { "if-none-match": "*" }, + body: "again", + }) + ).status, + ).toBe(412); + expect( + ( + await request(session, "PUT", "/dir/other.txt", { + headers: { "if-match": "*" }, + body: "no", + }) + ).status, + ).toBe(412); + await expect(driver.stat("/dir/other.txt")).rejects.toThrow(); + // A date form on nothing is ignored rather than refused (§13.1.4). + expect( + ( + await request(session, "PUT", "/dir/dated.txt", { + headers: { "if-unmodified-since": "Sun, 06 Nov 1994 08:49:37 GMT" }, + body: "created anyway", + }) + ).status, + ).toBe(201); + }); + + it("answers 423 before 412 on a locked resource", async () => { + /* Both preconditions failed; the lock is the one that says something + durable about the resource, and the one the client must resolve first. */ + const { session: locking } = lockingSession(); + await lockOf(locking, "/dir/file.txt"); + const reply = await request(locking, "PUT", "/dir/file.txt", { + headers: { "if-match": `"${"9".repeat(32)}"` }, + body: "no", + }); + expect(reply.status).toBe(423); + }); +}); + +// --------------------------------------------------------------------------- +// authentication +// --------------------------------------------------------------------------- + +describe("Basic authentication", () => { + const credentials = { username: "ada", password: "pass:word" }; + const encode = (text: string): string => Buffer.from(text, "utf8").toString("base64"); + + it("serves everything when no credentials are configured", async () => { + expect((await request(session, "OPTIONS", "/")).status).toBe(200); + }); + + it("challenges a request with no credentials", async () => { + const guarded = new WebdavSession(driver, { credentials }); + const reply = await request(guarded, "GET", "/dir/file.txt"); + expect(reply.status).toBe(401); + expect(reply.headers["www-authenticate"]).toBe(`Basic realm="mountx", charset="UTF-8"`); + expect(reply.text).toBe(""); + }); + + it("accepts the pair it was given, colons in the password included", async () => { + const guarded = new WebdavSession(driver, { credentials }); + const reply = await request(guarded, "GET", "/dir/file.txt", { + headers: { authorization: `Basic ${encode("ada:pass:word")}` }, + }); + expect(reply.status).toBe(200); + }); + + it("refuses base64 that decodes to something else than it says", async () => { + /* Node's decoder is sloppy — it will accept `YWQ` and answer one byte — so + a header that does not survive a re-encode is not the credential it + appears to be. */ + const guarded = new WebdavSession(driver, { credentials }); + const reply = await request(guarded, "OPTIONS", "/", { + headers: { authorization: "Basic YWQ" }, + }); + expect(reply.status).toBe(401); + }); + + it("refuses everything else", async () => { + const guarded = new WebdavSession(driver, { credentials, realm: 'the "share"' }); + for (const header of [ + `Basic ${encode("ada:wrong")}`, + `Basic ${encode("eve:pass:word")}`, + `Basic ${encode("ada")}`, + `Bearer ${encode("ada:pass:word")}`, + `Basic not-base64!`, + `Basic`, + ``, + ]) { + const reply = await request(guarded, "GET", "/dir/file.txt", { + headers: { authorization: header }, + }); + expect(reply.status, JSON.stringify(header)).toBe(401); + // The realm is quoted, so a quote in it cannot end the parameter early. + expect(reply.headers["www-authenticate"]).toBe(`Basic realm="the share", charset="UTF-8"`); + } + }); +}); + +// --------------------------------------------------------------------------- +// the one-reply discipline +// --------------------------------------------------------------------------- + +describe("one reply, always", () => { + it("carries a driver failure that is not absence through as its own status", async () => { + /* The difference `#statOrAbsent` turns on: `ENOENT` is "there is nothing + there" and everything else is a failure the client is owed. */ + const guarded = withFailure(driver, "stat", (path) => + path === "/dir/locked.txt" ? fsError("EACCES", { syscall: "stat", path }) : undefined, + ); + const reply = await request(new WebdavSession(guarded), "PUT", "/dir/locked.txt", { + body: "x", + }); + expect(reply.status).toBe(403); + }); + + it("turns an errno nothing has a name for into one 500", async () => { + const broken = withFailure(driver, "stat", (path) => + path === "/dir/file.txt" ? Object.assign(new Error("weird"), { code: "EWHAT" }) : undefined, + ); + const failing = new WebdavSession(broken); + expect((await request(failing, "GET", "/dir/file.txt")).status).toBe(500); + // And the next request is answered normally. + expect((await request(failing, "OPTIONS", "/")).status).toBe(200); + expect(failing.assertions).toEqual([]); + expect(failing.stats.replies).toBe(failing.stats.requests); + }); + + it("answers a body source that dies without leaving the request unanswered", async () => { + const reply = await session.handleRequest( + { method: "PUT", target: "/dir/torn", headers: {} }, + (async function* () { + yield Buffer.from("half", "utf8"); + throw new Error("the client hung up"); + })(), + ); + expect(reply.status).toBe(500); + expect(session.assertions).toEqual([]); + }); + + it("counts every request and every reply", async () => { + await request(session, "OPTIONS", "/"); + await request(session, "GET", "/nope"); + expect(session.stats.requests).toBe(2); + expect(session.stats.replies).toBe(2); + expect(session.stats.errors).toBe(1); + expect(session.stats.methods.get("GET")).toBe(1); + expect(session.stats.assertions).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// a driver that fails on demand +// --------------------------------------------------------------------------- + +/** + * The base driver with one method made to fail for chosen paths. + * + * The failure is keyed on the path so the rest of a recursive walk runs + * normally, which is what the `207` cases need: a tree where exactly one entry + * will not go. + */ +function withFailure( + base: FsDriver & { statfs?: (path: string) => Promise }, + method: "stat" | "unlink" | "mkdir" | "rmdir" | "readdir", + failure: (path: string) => Error | undefined, +): FsDriver { + const original = base[method] as (...args: unknown[]) => Promise; + return { + ...base, + [method]: async (path: string, ...rest: unknown[]) => { + const error = failure(path); + if (error !== undefined) { + throw error; + } + return await original.call(base, path, ...rest); + }, + } as FsDriver; +}