All docs

Docs · Reference

WebDAV

a folder as the UI

The webdav personality serves a tenant's drive to any WebDAV client — macOS Finder, Windows Explorer, rclone, Cyberduck, curl. Stacks write files into it with an op; a person drags files into it from their desktop. The head is a generic file server: it knows no product and never runs a stack to answer a request.

A drive is a mutable document space: files with paths that clients rename, rewrite and delete. What stays fixed is the resource id — minted once, kept through every rename and rewrite — so a stack that indexed a document under its id is not confused when a person moves it. Every committed mutation reaches the tenant’s _scheduled stack after the fact (drive.resource.{created, updated, deleted, moved}); a consumer fetches the bytes it wants by id with txco://drive/get.

The calendar and contacts personalities are this one’s siblings: same account model, same Basic-auth flow, same place on the web head. What differs is that WebDAV has no discovery and no principal — a login is bound to ONE collection and that collection is the root the client sees, so the mount URL is just the prefix.

Turn it on

txco serve --personalities cron,web,admin,webdav

There is no listener of its own: the head mounts on the web head under a reserved path prefix on every hostname it serves — --drive-path-prefix (default /drive). It must differ from the calendar and contacts prefixes (the chassis refuses to start otherwise). TLS is the web head’s. The index lives in its own SQLite file (--drive-db-path) and the bytes in a directory (--drive-objects-file-dir); the hosted build points both at shared backends (--drive-store=postgres, --drive-objects=s3) so every node serves one drive. For txco dev on plain HTTP add --drive-insecure-auth.

Provision

EXEC "txco://drive/collection" WITH name = "paris", into = "_dc"
EXEC "txco://drive/account"
  WITH username   = "paris@pony.example.com",
       collection = "paris",
       principal  = "pony:paris",
       into       = "_drvacct"
# → _drvacct.collection_id = "dc_…", _drvacct.mount = "/drive/paris/"

WHEN ._drvacct.created == true
  EXEC "txco://credential/create"
    WITH principal = ._drvacct.principal,
         scopes    = &concat("drive:", ._drvacct.collection_id, ":*"),
         password_style = "words"
# → _credential.password (once)

The username is <local>@<domain> where the domain is a verified hostname or delegated zone of the tenant — the rule every DAV head applies, so usernames are globally unique by construction. The account holds no password: it signs in as its principal with a credential whose scopes name the drive. A login opens drive:<collection-id>:login — the account’s own collection is what the principal may reach, and the credential’s scope can only narrow it: drive:dc_…:* opens that one drive, drive:*:* whichever the account is bound to, and a credential without a drive scope opens none. In a mount dialog a person may type just the local part; the head completes it with the request’s host.

Mount

clienthow
macOS FinderGo → Connect to Server → https://pony.example.com/drive/paris/, user paris
Windows ExplorerMap network drive → https://pony.example.com/drive/paris/
rclonerclone config type webdav, url https://pony.example.com/drive/paris/, vendor other; then rclone sync ./docs pony:
curlcurl -u paris@pony.example.com -T brief.md https://pony.example.com/drive/paris/brief.md

Name the collection in the URL. A client takes the volume’s name from the last path segment, so the bare /drive/ mounts as “drive” on every account — mount two and you get “drive” and “drive 1”. Ending the URL with the collection’s own name mounts it as “paris”. Both forms address the same tree and a session gets its hrefs back in the form it used, so anything already mounted at /drive/ keeps working. drive/account answers with the named form in mount. The one cost of the alias: a top-level directory that shares the collection’s name is addressed one level in, at /drive/paris/paris.

The head answers OPTIONS with DAV: 1, 2, 3 and LOCK/UNLOCK, which is what Finder needs to mount read-write; PROPFIND Depth: infinity is refused (403), as the RFC permits, so a listing is always one directory.

A folder the stack owns

A collection can reserve a subtree from clients: txco://drive/collection takes a policy of path prefix → verb → deny, the head enforces it, and the stack’s own ops are never subject to it (drive). The case it exists for is a curated folder — the stack puts documents somewhere and a desktop client must not overwrite them. It is worth reaching for, because a client with a stale cache can re-upload a file nobody edited: macOS did exactly that in the field, replacing a good PDF with one spliced at a 16 KiB boundary, and the server stored it faithfully because a WebDAV PUT carries no end-to-end checksum. Denying write on that tree is what makes the question moot.

Locks are a courtesy, not a guarantee

LOCK mints a fresh token and UNLOCK answers 204; nothing is stored and nothing is excluded. Two clients editing one file both “hold” a lock. The real protection is the etag: every PUT and DELETE honours If-Match / If-None-Match against the current sha256, and a client that sends them never overwrites a version it has not seen. This is what a lock can honestly be on a fleet with no shared lock state; it also keeps the head stateless across nodes — a LOCK, a PUT and an UNLOCK may each land on a different machine, and none of them consults lock state, so none of them can disagree.

A LOCK on a path that does not exist creates nothing. It answers 200 with a token, as it would for a file that exists, and the file comes into being when the client writes it. RFC 4918 says such a LOCK must create an empty resource, and this head once did — which left ghosts. A stack that files a document away moves it out from under a client that still has the old name cached; macOS takes a write lock even to preview a file, so the next glance at the stale name LOCKed it and the name came back as a zero-byte file nothing would ever clean up. A lock that reserves nothing should not write anything either. Clients are built for this: a lock on a missing name was only ever a reservation on servers with RFC 2518’s lock-null resources (Apache), RFC 4918 appendix D tells clients to expect either model, and macOS does not depend on it at all — it creates a file with a zero-byte PUT before it locks. The stale-name case now ends the way it should: LOCK 200, GET 404, and the client drops the entry. The lock still refuses what the write would refuse — a missing parent directory is 409, a tree whose policy denies write is 403.

Because the etag is the guarantee, the conditional headers are parsed as RFC 7232 writes them rather than as the one bare etag most clients send: a list (If-Match: "a", "b") is satisfied by any entry, a comma inside the quotes belongs to the etag, and If-Match compares strongly, so a W/ entry never satisfies it while If-None-Match ignores the prefix. Both are applied inside the write’s own transaction, so nothing slips between the check and the change.

What none of this can catch is a client that lies about its own bytes. The etag is the sha256 of what the server received, and WebDAV carries no end-to-end checksum on a PUT, so a client uploading from a damaged cache gets an etag that faithfully certifies the damage. Guarding against that is a matter of refusing the write (see the collection policy above), not of validating it.

Note also that macOS takes a write lock even to open a file for reading. A subtree whose policy denies write therefore refuses the LOCK as well as the PUT: telling the client up front is what makes it open the file read-only, instead of discovering the refusal at save time and retrying until the mount stalls.

Reads are ranged

A GET honours Range on every backend — 206 with Content-Range, a suffix (bytes=-64), a range that runs past the end (clamped, never padded), 416 with the file’s length when it starts past the end, and If-Range against the etag so a client resuming into a changed file gets the whole new file rather than a splice. HEAD advertises Accept-Ranges: bytes.

This is not a nicety. macOS webdavfs serves a read it has not cached as a Range GET, and a PDF reader’s first read is the tail (the cross-reference table lives there). A server that answers a Range request with 200 and the whole file is accepted by the client, which takes the file’s head as its tail — and every PDF opens as “damaged” while shasum over the mount says the bytes are perfect (prod, 2026-09-17). The store returns each object as a seekable reader whose size is the index’s, so the range is positioned before anything is opened, and a backend that can open part of an object (S3) sends only that part through the server.

Limits and housekeeping

--drive-max-file-bytes (4 GiB)a PUT that declares more is 413 before a byte moves; a PUT without a Content-Length (Finder streams a dragged file that way) is accepted and stops at the cap
--drive-max-collection-bytes, --drive-max-resources (unlimited)a write past them is 507
--login-rate (30/min)password checks per client IP and per principal, shared with the IMAP, CalDAV and CardDAV heads, counted only on verified-login-cache misses; over it is 429
--web-trusted-proxies (none)the HTTP proxies in front of the web head: behind one, “per client IP” means the client the proxy recorded in X-Forwarded-For, not the proxy — without it every mount shares one budget (serve.md)
--drive-sweep-period (15 min)on webdav nodes: superseded versions and the objects of failed writes are reclaimed after --drive-sweep-grace (1 h); tombstones are hard-deleted after --drive-tombstone-retention (7 d)

Every refused login logs one webdav login line with its outcome; a successful one logs only when it checks the password (once per 5-minute login cache per node), not on every request a mounted drive makes. The chassis.webdav.logins metric still counts every outcome, cache hits included.

A PUT streams straight to the object store — memory is not the bound — with a read deadline that scales with the declared size, so a 4 GiB upload gets its two hours while an abandoned stream is still reaped.

What a stack sees

Nothing, until it subscribes: the drive’s mutations are _scheduled events, so a stack with an OPS/_scheduled/ tree keyed on @scheduled.payload.event =~ /^drive\./ hears every write. The payload carries the facts (collection, resource id, path, etag, size, content type) and never the bytes; txco://drive/get by resource_id fetches them. A folder rename, copy or delete reports the folder and then every file below it, one event each, so a consumer keyed on files never walks the tree. A file the stack chooses not to parse is still there for the client — stored, not indexed is a valid state, and the stack’s own status record is where it says so.

What a client does maps onto events like this: macOS creates a file as a zero-byte PUT (→ created, size 0), a LOCK (no event — a lock never writes), then the bytes (→ updated) and an UNLOCK; curl and rclone send the bytes at once (→ created). Finder also writes .DS_Store and ._* sidecars into every folder it touches; a consumer that indexes files should skip any segment starting with a dot.

Ops reference: drive.

Edit this page · View as markdown