mountx logomountx

WebDAV

The other transport that is not a mount: serve a driver over HTTP to anything that speaks WebDAV.

mountx/webdav implements RFC 4918 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) it sits outside mountx/auto.

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:

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

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

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

  • No credentials — every request is served unauthenticated, so the bind is loopback-only: a non-loopback host0.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
OPTIONSDAV: 1, 2, 3, Allow, MS-Author-Via: DAV. Answered without touching the driver, for any target
GET/HEADthe bytes, with ETag, Last-Modified and a single Range (206, or 416 when unsatisfiable)
PUT201 when it created, 204 when it replaced. Content-Range is refused (400)
DELETE204, or a 207 naming what would not go. Depth on a collection must be infinity
MKCOL201. A request body is 415; an existing resource is 405
COPYDepth 0 or infinity; 201/204, or 207 for a tree that only partly copied
MOVEDepth: infinity only, and a rename underneath — so it is atomic when the driver's is
PROPFINDDepth: 0 or 1, 207 multistatus. infinity is 403 propfind-finite-depth
PROPPATCH207 per property: 200 for getlastmodified, 403 cannot-modify-protected-property for the rest
LOCK200 on a resource, 201 on a URL with nothing at it, 423 when a lock is in the way — see locking
UNLOCK204. 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 — 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, 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 /.

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.

# 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 Failedthe header was there and no state list was true — your copy is stale, re-read the resource
423 Locked + lock-token-submittedthe request would change a locked resource and did not carry its token; the hrefs name the lock roots
207 carrying 423the 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 and the resource's Last-Modified, by the same code the S3 gateway 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?)

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, because a refusal that waits for listen() is a refusal that has already opened a socket.

#WebdavServerOptions

Extends WebdavSessionOptions, so everything the session takes is settable here too.

optiondefault
host"127.0.0.1"address to bind — see Who may connect
port0an ephemeral port, which WebdavServer.port then reports; never 80 or 8080
credentialsnone{ username, password } — present enables Basic auth and any bind
realm"mountx"the realm named in WWW-Authenticate
drainTimeout5000ms close() lets in-flight responses finish before dropping connections
onTransportErrornone(error, peer) — a socket error, or a reply that could not be written

#WebdavServer

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()

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

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.

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

optiondefault
credentialsnone{ username, password }; present authenticates every request
realm"mountx"the realm named in WWW-Authenticate
maxBodyBytesunlimitedcap on a PUT body; over it is 413
maxXmlBytes256 KiBcap on a PROPFIND/PROPPATCH/LOCK document
readChunkBytes128 KiBbytes per positional read while streaming a GET
nowDate.nowthe clock a lock's lease is measured against
lockssee locking{ defaultTimeoutSeconds, maxTimeoutSeconds, maxLocks, newToken }
debugon outside productionrun the reply-exactly-once assertions
onErrornonecalled for every request that ends in an error reply
onAssertioncollectcalled when a dev-mode assertion fails

#The layers below

module
constants.tsthe errno → HTTP status table (total over every ErrnoCode), the protocol's literals, and the propstat phrases
protocol.tspure parsing and document building — target ↔ href, Depth/Overwrite/Destination/Timeout/Lock-Token/If, and every document
locks.tsDavLockTable — the write locks: pure, synchronous and clockless, with now an argument rather than a call
session.tsWebdavSession — the method semantics, over one driver
server.tsthe 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 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 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 — the other transport that serves rather than mounts.
  • FUSE, 9P and NFS — the three that produce a mountpoint.
mountx logo

mountx  Write a filesystem in JavaScript, mount it as a real folder.