
# 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:<port>

// 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 '<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D="DAV:">
  <D:lockscope><D:exclusive/></D:lockscope>
  <D:locktype><D:write/></D:locktype>
  <D:owner>ada</D:owner>
</D:lockinfo>' "$URL/notes/draft.txt"     # 201 Created, Lock-Token: <urn:uuid:...>

curl -T ./draft.txt "$URL/notes/draft.txt"                       # 423 Locked
curl -H "If: (<urn:uuid:...>)" -T ./draft.txt "$URL/notes/draft.txt"   # 204
curl -X UNLOCK -H "Lock-Token: <urn:uuid:...>" "$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 (`<urn:uuid:...>`) 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: </notes/> (<urn:uuid:...>)
```

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 <DAV:no-lock>)` 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<WebdavServer>; // idempotent; resolves once bound
  close(): Promise<void>; // 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<string, number>, 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<Uint8Array>`, 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 — `<multistatus xmlns="DAV:">` 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). A property in another namespace re-declares as it is written, so it goes back out named the way it came in.

Coming in, a **property** name is the pair §4 defines it to be — a namespace and a local name — and both halves decide which property was named. Explorer's `Win32*` properties are in `urn:schemas-microsoft-com:`, so they are properties this server does not have, and a `getlastmodified` in that namespace is not this server's `getlastmodified`. The **structural** elements of the three request grammars (`<propfind>`, `<prop>`, `<set>`, `<lockinfo>`) are matched on their local name and their namespace is not checked: they identify nothing, and checking them would refuse the clients that send these bodies with no declaration at all.
::

## 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.
- **Any property outside `DAV:`.** Every property this server has is in `DAV:` — RFC 4918's own and RFC 4331's quota pair. A name in another namespace lands in the `404` propstat, named in the namespace it was asked about.
- **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.
