From 8bd1590bd219969e82a31d8727a26cd18cf34117 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:04:30 +0000 Subject: [PATCH 01/13] refactor(http): one copy of RFC 9110's dates, Range and ETag `HTTP-date`, the `Range` grammar and the `ETag` quoting are facts about RFC 9110, not about S3, and `mountx/webdav` needs the same three. They move to `src/http.ts`; `src/s3/protocol.ts` re-exports every symbol under the name it already had, so `mountx/s3`'s surface is unchanged. Same argument as the one errno table in `src/errors.ts` (AGENTS.md, invariant 6): a wire format transcribed twice is a wire format that will be transcribed differently twice. Co-Authored-By: Claude Opus 5 --- src/http.ts | 281 +++++++++++++++++++++++++++++++++++++++++++ src/s3/protocol.ts | 288 ++++----------------------------------------- 2 files changed, 305 insertions(+), 264 deletions(-) create mode 100644 src/http.ts diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..1750bbe --- /dev/null +++ b/src/http.ts @@ -0,0 +1,281 @@ +/** + * The HTTP the two HTTP transports share: `HTTP-date`, `Range`, `ETag`. + * + * 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 }; +} + +/** 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}`; +} diff --git a/src/s3/protocol.ts b/src/s3/protocol.ts index df847ab..beb1bb6 100644 --- a/src/s3/protocol.ts +++ b/src/s3/protocol.ts @@ -48,6 +48,13 @@ import { MAX_PARTS, MULTIPART_PREFIX, } from "./constants.ts"; +import { + formatContentRange, + formatETag, + formatHttpDate, + MAX_TIMESTAMP_MS, + parseHttpDate, +} from "../http.ts"; import { normalizePath } from "../path.ts"; import type { HeaderEntry, QueryEntry, SigV4RefusalReason } from "./sigv4.ts"; import type { XmlRefusal } from "./xml.ts"; @@ -489,151 +496,26 @@ 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. - */ -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). +/* + * `HTTP-date`, the `Range` grammar and the `ETag` quoting are RFC 9110 rather + * than S3, and `mountx/webdav` answers the same three. 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. */ -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, + parseHttpDate, + parseRange, + type RangeSpec, +} from "../http.ts"; // --------------------------------------------------------------------------- // the request target @@ -1635,112 +1517,6 @@ 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 // --------------------------------------------------------------------------- @@ -1933,22 +1709,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. */ From 51a4454af2c9676322f0c6b9c3762df596e31511 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:04:42 +0000 Subject: [PATCH 02/13] feat(webdav): a minimal RFC 4918 class-1 server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mountx/webdav` serves an `FsDriver` over WebDAV: OPTIONS, HEAD, GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND and PROPPATCH, in the transport shape the rest of `src/` uses — constants, a pure protocol layer, a session that never rejects, and a server that is the only file importing `node:http`. Like `mountx/s3` it produces no mountpoint, so it stays outside `mountx/auto`. Class 2 is absent and says so: no LOCK/UNLOCK, and the `DAV` header answers `1, 3` rather than claiming a class whose methods would be 405 (invariant 5). That costs a writable mount on macOS's `mount_webdav` and on the Windows redirector, which is written down at the session's header and tracked in `.agents/roadmap.md` rather than discovered later. Two recursive operations refuse to follow a symbolic link, and both would be destructive if they did: DELETE takes an `lstat` and recurses on the readdir dirent, so it removes the link rather than emptying what it points at; COPY reports a link to a collection with 403 instead of descending into one, since a link back to an ancestor makes the walk revisit a subtree it is still writing into. `src/webdav/protocol.ts` imports `src/s3/xml.ts` — the second deliberate cross-transport dependency after 9P -> fuse/flags.ts — rather than hardening a second bounded XML parser. What that costs is namespaces, documented in full at that file's header. Tests: the wire on its own, the session in-process against the memory driver, the server over real sockets, and an oracle running real rclone and curl, gated on `command -v` so `pnpm test` stays green without them. Co-Authored-By: Claude Opus 5 --- .agents/architecture.md | 40 +- .agents/invariants.md | 17 +- .agents/roadmap.md | 58 +- .agents/testing.md | 22 + AGENTS.md | 8 +- README.md | 1 + build.config.ts | 1 + docs/2.transports/0.index.md | 15 +- docs/2.transports/6.webdav.md | 237 ++++++ docs/3.reference/0.index.md | 3 +- docs/3.reference/2.capabilities.md | 2 +- package.json | 4 + src/webdav/constants.ts | 240 ++++++ src/webdav/index.ts | 32 + src/webdav/protocol.ts | 559 ++++++++++++ src/webdav/server.ts | 605 +++++++++++++ src/webdav/session.ts | 1259 ++++++++++++++++++++++++++++ test/index.test.ts | 1 + test/webdav/oracle.test.ts | 333 ++++++++ test/webdav/protocol.test.ts | 390 +++++++++ test/webdav/server.test.ts | 457 ++++++++++ test/webdav/session.test.ts | 870 +++++++++++++++++++ 22 files changed, 5132 insertions(+), 22 deletions(-) create mode 100644 docs/2.transports/6.webdav.md create mode 100644 src/webdav/constants.ts create mode 100644 src/webdav/index.ts create mode 100644 src/webdav/protocol.ts create mode 100644 src/webdav/server.ts create mode 100644 src/webdav/session.ts create mode 100644 test/webdav/oracle.test.ts create mode 100644 test/webdav/protocol.test.ts create mode 100644 test/webdav/server.test.ts create mode 100644 test/webdav/session.test.ts diff --git a/.agents/architecture.md b/.agents/architecture.md index 5f15695..9c93209 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -36,6 +36,7 @@ Deviations are noted per area below. | `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` and `ETag` quoting — the one copy, shared by the two HTTP transports; `mountx/s3` re-exports every symbol under its own name | | `auto.ts` | `mountx/auto` — probe, then FUSE → 9P → NFS, each behind `await import()` | ### Drivers (`src/drivers/`) @@ -122,6 +123,25 @@ 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 class 1** — every method but `LOCK`/`UNLOCK` — 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`, the two request grammars, `multistatus` and `error` | +| `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 locking (so the `DAV` +header says `1, 3`, never `1, 2, 3`), no dead properties (`PROPPATCH` answers `403 +cannot-modify-protected-property` — a driver has nowhere to keep one), and no +conditional requests (they arrive with `If`, which exists to carry lock tokens). + ## 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 +217,24 @@ 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` and the `Content-Range`/`ETag` spellings, all + RFC 9110. It was `src/s3/protocol.ts`'s until WebDAV needed the same three; + `s3/protocol.ts` re-exports every symbol under its old name, so `mountx/s3`'s + surface never moved. - **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/invariants.md b/.agents/invariants.md index ed2eede..68ce512 100644 --- a/.agents/invariants.md +++ b/.agents/invariants.md @@ -48,7 +48,10 @@ 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`, which `src/s3/protocol.ts` re-exports under its own +names and `src/webdav/` imports directly — one transcription, two HTTP transports. ## Wire protocols @@ -56,9 +59,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 +82,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..80091b1 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,48 @@ 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 + +- **No `LOCK`/`UNLOCK`, so the share is class 1.** This is the one gap with a + visible cost rather than a theoretical one: macOS's `mount_webdav` mounts a + class-1 share **read-only**, and the Windows redirector has its own + objections, so the transport that exists to be the unprivileged mount path + cannot yet be an unprivileged _writable_ mount path on either. What it needs + is a lock table (tokens, timeouts, depth-0 and depth-infinity scope) plus the + `If` header, which is the other half — a request proves it holds a lock by + carrying the token there, and implementing `If` without locks would be + answering a question nothing can ask. `src/9p/locks.ts` is the nearest + precedent for the table, though the semantics are not the same: 9P's are POSIX + byte ranges owned by a client/proc pair, WebDAV's are whole-resource (or + whole-subtree) and owned by a token the server minted. +- **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 conditional requests.** `If-Match`, `If-None-Match`, `If-Modified-Since` + and `If-Unmodified-Since` are ignored rather than half-honoured. `mountx/s3` + implements RFC 9110's four over the same derived ETag, so the codec is + written; what is deliberate is the _timing_ — doing these without `If` would + leave the one header WebDAV adds as the conspicuous hole, so they arrive with + the locking work above. +- **`PROPPATCH` cannot store anything.** Every property is refused with `403 +cannot-modify-protected-property`, which is truthful for a server whose + properties are all live, and is also what makes a client that sets + `Win32LastModifiedTime` (Finder, Explorer) report a failure it did not expect. + The cheap half is `getlastmodified` → `driver.utimes()` for a driver + declaring `times`; the expensive half is dead properties, which need a store + the driver interface does not have — a sidecar file would show up in every + listing. +- **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 +300,10 @@ 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 — but + nothing here has been run on Windows, and the redirector wants class-2 + locking before it will write (see the WebDAV section). 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 c45c8c6..4698eec 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -98,6 +98,20 @@ 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 two + request grammars, the `multistatus`/`error` 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), `session.test.ts` (in-process + against the memory driver, no sockets: RFC 4918's per-method semantics, 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, the same role rclone plays for the S3 gateway. **No + conformance column yet** — see Known gaps. - `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. @@ -109,6 +123,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..0fa016b 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 class 1 over HTTP; no locking, and the `DAV` header says so | | `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..56133ce 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 class 1"] + 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 class 1, 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..6958e11 --- /dev/null +++ b/docs/2.transports/6.webdav.md @@ -0,0 +1,237 @@ +--- +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) **class 1** — every method the specification defines except `LOCK` and `UNLOCK` — 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 (read-only here — see the note on locking below) +mount_webdav -S http://127.0.0.1:PORT /Volumes/mountx +``` + +::warning +**A class-1 share is read-only to macOS's `mount_webdav`**, and the Windows redirector has its own objections. Both want class 2 — WebDAV locking — before they will write, and locking is [not implemented](#no-locking-yet). Every client that speaks the protocol directly rather than through a kernel mount (`rclone`, `curl`, `cadaver`, a browser, `davfs2`) reads _and_ writes normally. +:: + +## 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, 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` with `403 cannot-modify-protected-property` per property — see [properties](#properties-are-all-live) | + +Anything else — `LOCK`, `UNLOCK`, `REPORT`, `PATCH` — 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`, and none are stored: `creationdate`, `displayname`, `getcontentlength`, `getcontenttype`, `getetag`, `getlastmodified`, `resourcetype`, plus an empty `supportedlock` and `lockdiscovery` (truthfully empty — there are no locks). 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. + +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. + +`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. + +## No locking yet + +`LOCK` and `UNLOCK` are absent, the `DAV` header says `1, 3` rather than `1, 2, 3`, and the two methods answer `405`. That is the [capabilities rule](/reference/capabilities) applied to a protocol header: an unmet capability answers honestly rather than pretending. + +The cost is real and worth stating plainly — it is what makes macOS's `mount_webdav` mount read-only, and it is why the `If` header (which exists to carry lock tokens) is not implemented either. Locking is the next piece of work on this transport, not an oversight. + +## `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.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` document | +| `readChunkBytes` | 128 KiB | bytes per positional read while streaming a `GET` | +| `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`, `multistatus` and `error` | +| `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 + +- **`LOCK`/`UNLOCK` and the `If` header** — see [above](#no-locking-yet). +- **Dead properties.** `PROPPATCH` refuses every property rather than accepting one it cannot keep. +- **Conditional requests.** `If-Match`, `If-None-Match` and the two date forms are ignored rather than half-honoured; they arrive with `If`, which is the one WebDAV adds. +- **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..4809b5f 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" diff --git a/src/webdav/constants.ts b/src/webdav/constants.ts new file mode 100644 index 0000000..753ef5b --- /dev/null +++ b/src/webdav/constants.ts @@ -0,0 +1,240 @@ +/** + * 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: **class 1 and class 3** + * (RFC 4918 §10.1, §18). + * + * Class 1 is "everything in RFC 4918 except locking". Class 3 is "this server + * is RFC 4918 rather than RFC 2518", which is a statement about the *revision* + * and is independent of locking. **Class 2 is deliberately absent**: there is + * no `LOCK` here, and advertising a class whose methods answer `405` is exactly + * the "capabilities are declared-or-inferred, never faked" rule (`AGENTS.md`, + * invariant 5) applied to a protocol header. See `src/webdav/session.ts` for + * which clients that costs. + */ +export const DAV_COMPLIANCE = "1, 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", +] 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; + +// --------------------------------------------------------------------------- +// 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` and `507` are RFC 4918's own; `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", + 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..2184eee --- /dev/null +++ b/src/webdav/index.ts @@ -0,0 +1,32 @@ +/** + * 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. + * + * **Class 1 of RFC 4918** — every method except `LOCK` and `UNLOCK`, and the + * `DAV` header says so rather than claiming a class 2 that is not there. + * `src/webdav/session.ts`'s header sets out what that costs and which clients + * it costs it with. + * + * 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`, the + * two request grammars, `multistatus` and `error`. + * - `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 "./protocol.ts"; +export * from "./server.ts"; +export * from "./session.ts"; diff --git a/src/webdav/protocol.ts b/src/webdav/protocol.ts new file mode 100644 index 0000000..442a110 --- /dev/null +++ b/src/webdav/protocol.ts @@ -0,0 +1,559 @@ +/** + * 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` and `Destination` headers, the URL ↔ driver-path + * mapping in both directions, the two request grammars, and the `multistatus` + * and `error` documents. Sources are **RFC 4918** for the DAV parts and + * **RFC 9110** for the HTTP ones, named at the rule they justify. + * + * ## 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"; + +// --------------------------------------------------------------------------- +// 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; + /** Extra headers the refusal carries (`Allow`, `Content-Range`). */ + readonly headers: Record; + + constructor( + status: number, + options: { condition?: string; message?: string; headers?: Record } = {}, + ) { + super(options.message ?? statusLine(status)); + this.name = "DavFault"; + this.status = status; + this.condition = options.condition; + 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; 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" } }; + } + return xmlBody(status, encodeErrorDocument(condition), 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); +} + +// --------------------------------------------------------------------------- +// 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 }; +} + +/** + * The properties a `PROPPATCH` body wants written or removed (RFC 4918 §9.2). + * + * Both lists are kept even though this server writes neither: 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. + */ +export interface ProppatchRequest { + /** Property names under ``, in request order. */ + set: string[]; + /** 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: string[] = []; + const remove: string[] = []; + for (const child of root.children) { + const into = child.name === "set" ? set : child.name === "remove" ? remove : undefined; + if (into === undefined) { + continue; + } + for (const prop of child.children) { + if (prop.name === "prop") { + for (const property of prop.children) { + into.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 }; +} + +/** + * 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). */ +export function encodeErrorDocument(condition: string): string { + return xmlDocument({ name: "error", children: [{ name: condition }] }, { 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..6c4a864 --- /dev/null +++ b/src/webdav/session.ts @@ -0,0 +1,1259 @@ +/** + * 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 "minimal" means here, exactly + * + * **Class 1 of RFC 4918, complete; class 2 absent.** `OPTIONS`, `HEAD`, `GET`, + * `PUT`, `DELETE`, `MKCOL`, `COPY`, `MOVE`, `PROPFIND` and `PROPPATCH` are + * implemented against the driver. `LOCK` and `UNLOCK` are not, the `DAV` header + * says `1, 3` rather than `1, 2, 3`, and the two methods answer `405` with an + * `Allow` listing what is really there — declared-or-inferred, never faked + * (`AGENTS.md`, invariant 5). + * + * That is a decision with a visible cost, so it is written down rather than + * discovered: **macOS's `mount_webdav` mounts a class-1 share read-only**, and + * the Windows redirector is unhappy in its own ways. A client that speaks the + * protocol rather than the mount — `rclone`, `curl`, `cadaver`, a browser, most + * Linux clients under `davfs2` — reads and writes normally. Locking is the next + * piece of work, not an oversight; see `.agents/roadmap.md`. + * + * The other 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 everything, which is the + * truthful answer for a server whose properties are all live and all derived. + * - **No conditional requests.** `If`, `If-Match`, `If-None-Match` and the two + * date forms are ignored rather than half-honoured. `mountx/s3` implements + * RFC 9110's four; doing the same here without `LOCK` would leave `If` — the + * one WebDAV adds, and the one that exists to carry lock tokens — as the + * conspicuous hole. They arrive together. + * - **`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. + * + * ## 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 properties this server has + * + * All live, all derived from one `stat`, and none of them stored: + * `creationdate`, `displayname`, `getcontentlength`, `getcontenttype`, + * `getetag`, `getlastmodified`, `resourcetype`, `supportedlock` (empty — see + * above) and `lockdiscovery` (empty, and always will be). 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 { formatETag, formatHttpDate, formatIsoDate, 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 { + collectBody, + encodeMultistatus, + faultResponse, + hrefOf, + NO_BODY, + parseDepth, + parseDestination, + parseOverwrite, + parsePropfind, + parseProppatch, + parseTargetPath, + refuse, + statusOfError, + xmlBody, + type Depth, + type MultistatusEntry, + type Propstat, + 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; + /** 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; +} + +// --------------------------------------------------------------------------- +// 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; + 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 #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.#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); + switch (method) { + case "GET": + case "HEAD": { + return await this.#get(head, path); + } + case "PUT": { + return await this.#put(head, path, body); + } + case "DELETE": { + return await this.#delete(head, path); + } + case "MKCOL": { + return await this.#mkcol(path, body); + } + case "COPY": + case "MOVE": { + return await this.#copyOrMove(head, path, method === "MOVE"); + } + case "PROPFIND": { + return await this.#propfind(head, path, body); + } + case "PROPPATCH": { + return await this.#proppatch(path, body); + } + default: { + /* Everything else, `LOCK` and `UNLOCK` included: the `Allow` header is + the honest list, and a client reading it learns this is a class-1 + server 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" }); + } + 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) }; + } + + /** 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, + ): 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)); + 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): 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" }); + } + if (!stats.isDirectory()) { + await this.driver.unlink(path); + return { status: 204, headers: { "content-length": "0" } }; + } + const failures = await this.#deleteTree(path); + 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): 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)); + 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): 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 (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); + 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); + return { status: existing === undefined ? 201 : 204, headers: { "content-length": "0" } }; + } + const failures = await this.#copyTree(path, destination, stats, depth === "infinity"); + 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, + ): 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); + const entries: MultistatusEntry[] = [ + { + href: hrefOf(path, stats.isDirectory()), + propstat: await this.#propstats(path, stats, request), + }, + ]; + 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), + }); + } + } + 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, + ): 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); + 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): 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": + case "lockdiscovery": { + /* Both empty, and both truthful: no lock type is supported and no lock + is ever held. Sending them at all is what tells a client it need not + ask. */ + return { name }; + } + 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 + // ------------------------------------------------------------------------- + + /** + * Refuse to write properties, in the form §9.2 requires. + * + * Every property in the request is named in the reply with `403` and the + * `cannot-modify-protected-property` condition (§16), because every property + * this server has is a live one derived from the driver's own metadata and + * there is nowhere to put a dead one (see the module docs). The status is a + * `207` rather than a plain `403`: §9.2 requires the per-property form + * whenever the request named more than nothing, and a client that sent one + * property it could have set alongside one it could not needs to see which + * was which. + */ + async #proppatch(path: string, body: AsyncIterable): Promise { + const request = parseProppatch(await collectBody(body, this.#maxXmlBytes)); + const stats = await this.#stat(path); + const names = [...request.set, ...request.remove]; + return xmlBody( + 207, + encodeMultistatus([ + { + href: hrefOf(path, stats.isDirectory()), + propstat: [ + { + status: 403, + props: names.map((name) => ({ name })), + condition: "cannot-modify-protected-property", + }, + ], + }, + ]), + ); + } + + // ------------------------------------------------------------------------- + // 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/oracle.test.ts b/test/webdav/oracle.test.ts new file mode 100644 index 0000000..aeeff7e --- /dev/null +++ b/test/webdav/oracle.test.ts @@ -0,0 +1,333 @@ +/** + * 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(); + 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( + "advertises class 1 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, 3"); + expect(headers.toLowerCase()).toContain("ms-author-via: dav"); + // Class 2 is not advertised, and the method list says the same thing. + expect(headers).not.toContain("LOCK"); + + 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..ccb24ae --- /dev/null +++ b/test/webdav/protocol.test.ts @@ -0,0 +1,390 @@ +/** + * 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`, the two + * request bodies, and the two 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 { + collectBody, + DavFault, + encodeErrorDocument, + encodeMultistatus, + faultResponse, + hrefOf, + isDavFault, + parseDepth, + parseDestination, + parseOverwrite, + parsePropfind, + parseProppatch, + parseTargetPath, + refuse, + statusOfError, +} from "../../src/webdav/protocol.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: ["displayname"], remove: ["mine"] }); + }); + + it("ignores a child that is neither set nor remove", () => { + expect( + parseProppatch( + utf8( + `` + + ``, + ), + ), + ).toEqual({ set: ["displayname"], 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( + `` + + ``, + ); + }); +}); diff --git a/test/webdav/server.test.ts b/test/webdav/server.test.ts new file mode 100644 index 0000000..877f336 --- /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, 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..5b4af88 --- /dev/null +++ b/test/webdav/session.test.ts @@ -0,0 +1,870 @@ +/** + * 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 class 1 and 3, and never class 2", async () => { + const reply = await request(session, "OPTIONS", "/"); + expect(reply.status).toBe(200); + expect(reply.headers["dav"]).toBe("1, 3"); + expect(reply.headers["allow"]).toContain("PROPFIND"); + expect(reply.headers["allow"]).not.toContain("LOCK"); + 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 ["LOCK", "UNLOCK", "PATCH", "REPORT"]) { + 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("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 403 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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; +} From f0d63f91f19d5c9d148844f55b22a5e1a62685de Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:26:58 +0000 Subject: [PATCH 03/13] feat(webdav): class 2, the lock table and LOCK/UNLOCK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 4918 §6 and §7 over one driver: a pure, synchronous, clockless lock table (`src/webdav/locks.ts` — `now` is an argument, the token minter is an option), `LOCK`, `UNLOCK`, and the `Timeout`, `Lock-Token` and `If` grammars in `protocol.ts`. Both scopes and both depths, §9.10.5's compatibility table, leases that lapse without a timer, refresh through `If` (§9.10.2), and §7.3's locked empty resource for a `LOCK` on an unmapped URL. `supportedlock` and `lockdiscovery` stop being truthfully empty and report what is granted and held, which is what makes `DAV: 1, 2, 3` a statement rather than a claim. A lock never follows its resource (§7.6): every `DELETE`, `MOVE` and `COPY` deletes the locks whose roots it unmapped (§6.1 point 8), each root checked rather than assumed. The `If` header is parsed here and enforced in the next commit — a mutating request does not yet have to carry the token it holds. Co-Authored-By: Claude Opus 5 --- .agents/architecture.md | 30 +- AGENTS.md | 2 +- src/webdav/constants.ts | 84 +++++- src/webdav/index.ts | 16 +- src/webdav/locks.ts | 439 ++++++++++++++++++++++++++++ src/webdav/protocol.ts | 551 ++++++++++++++++++++++++++++++++++- src/webdav/session.ts | 372 ++++++++++++++++++++--- test/webdav/locks.test.ts | 269 +++++++++++++++++ test/webdav/oracle.test.ts | 9 +- test/webdav/protocol.test.ts | 283 +++++++++++++++++- test/webdav/server.test.ts | 2 +- test/webdav/session.test.ts | 342 +++++++++++++++++++++- 12 files changed, 2310 insertions(+), 89 deletions(-) create mode 100644 src/webdav/locks.ts create mode 100644 test/webdav/locks.test.ts diff --git a/.agents/architecture.md b/.agents/architecture.md index 9c93209..7846b56 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -127,20 +127,22 @@ is no RFC; everything is transcribed from Amazon's docs and named where it is us 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 class 1** — every method but `LOCK`/`UNLOCK` — 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`, the two request grammars, `multistatus` and `error` | -| `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 locking (so the `DAV` -header says `1, 3`, never `1, 2, 3`), no dead properties (`PROPPATCH` answers `403 -cannot-modify-protected-property` — a driver has nowhere to keep one), and no -conditional requests (they arrive with `If`, which exists to carry lock tokens). +**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` answers `403 cannot-modify-protected-property` — a driver has nowhere +to keep one), no conditional requests yet, 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) diff --git a/AGENTS.md b/AGENTS.md index 0fa016b..7722863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ any rule below that looks removable. | `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 class 1 over HTTP; no locking, and the `DAV` header says so | +| `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/src/webdav/constants.ts b/src/webdav/constants.ts index 753ef5b..c62d360 100644 --- a/src/webdav/constants.ts +++ b/src/webdav/constants.ts @@ -29,18 +29,23 @@ import type { ErrnoCode } from "../errors.ts"; export const DAV_NS = "DAV:"; /** - * The `DAV` response header this server sends: **class 1 and class 3** + * 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 3 is "this server - * is RFC 4918 rather than RFC 2518", which is a statement about the *revision* - * and is independent of locking. **Class 2 is deliberately absent**: there is - * no `LOCK` here, and advertising a class whose methods answer `405` is exactly - * the "capabilities are declared-or-inferred, never faked" rule (`AGENTS.md`, - * invariant 5) applied to a protocol header. See `src/webdav/session.ts` for - * which clients that costs. + * 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, 3"; +export const DAV_COMPLIANCE = "1, 2, 3"; /** * The header Microsoft's WebDAV redirector looks for before it will treat an @@ -63,6 +68,8 @@ export const WEBDAV_METHODS = [ "MOVE", "PROPFIND", "PROPPATCH", + "LOCK", + "UNLOCK", ] as const; /** One of {@link WEBDAV_METHODS}. */ @@ -108,6 +115,60 @@ 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 // --------------------------------------------------------------------------- @@ -119,8 +180,8 @@ export const READ_CHUNK_BYTES = 128 * 1024; * `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` and `507` are RFC 4918's own; `508` is RFC 5842's; the rest are - * RFC 9110 §15. + * `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", @@ -139,6 +200,7 @@ export const STATUS_TEXT: Record = { 414: "URI Too Long", 415: "Unsupported Media Type", 416: "Range Not Satisfiable", + 423: "Locked", 424: "Failed Dependency", 500: "Internal Server Error", 501: "Not Implemented", diff --git a/src/webdav/index.ts b/src/webdav/index.ts index 2184eee..d2f648a 100644 --- a/src/webdav/index.ts +++ b/src/webdav/index.ts @@ -7,18 +7,21 @@ * it is outside `mountx/auto` for the same reason `mountx/s3` is: `auto`'s * contract is a mountpoint, and this never makes one. * - * **Class 1 of RFC 4918** — every method except `LOCK` and `UNLOCK`, and the - * `DAV` header says so rather than claiming a class 2 that is not there. - * `src/webdav/session.ts`'s header sets out what that costs and which clients - * it costs it with. + * **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`, the - * two request grammars, `multistatus` and `error`. + * 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`. * @@ -27,6 +30,7 @@ */ 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..1a2d723 --- /dev/null +++ b/src/webdav/locks.ts @@ -0,0 +1,439 @@ +/** + * 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 + * ({@link DavLockTable.discard}), 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); + } + + /** + * Delete every lock rooted at `path` or under it, and answer which went. + * + * §6.1 point 8, and it is the *only* thing that happens to a lock when its + * resource moves or goes away: "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". Called by `DELETE` and by the source side of a `MOVE`; a lock + * rooted *above* the path is untouched, because its own root is still mapped. + */ + discard(path: string, now: number): DavLock[] { + const doomed = this.within(path, now); + for (const lock of doomed) { + this.#locks.delete(lock.token); + } + return doomed; + } + + /** + * 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 index 442a110..42e79d1 100644 --- a/src/webdav/protocol.ts +++ b/src/webdav/protocol.ts @@ -4,10 +4,17 @@ * * 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` and `Destination` headers, the URL ↔ driver-path - * mapping in both directions, the two request grammars, and the `multistatus` - * and `error` documents. Sources are **RFC 4918** for the DAV parts and - * **RFC 9110** for the HTTP ones, named at the rule they justify. + * 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 * @@ -32,6 +39,7 @@ 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 @@ -103,17 +111,33 @@ export class DavFault extends Error { 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; message?: string; headers?: Record } = {}, + 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 ?? {}; } } @@ -126,7 +150,12 @@ export function isDavFault(error: unknown): error is DavFault { /** Shorthand for `throw refuse(409)` at the call sites that read better that way. */ export function refuse( status: number, - options?: { condition?: string; message?: string; headers?: Record }, + options?: { + condition?: string; + hrefs?: readonly string[]; + message?: string; + headers?: Record; + }, ): DavFault { return new DavFault(status, options); } @@ -165,7 +194,8 @@ export function faultResponse(error: unknown): WebdavResponse { if (condition === undefined) { return { status, headers: { ...extra, "content-length": "0" } }; } - return xmlBody(status, encodeErrorDocument(condition), extra); + 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. */ @@ -342,6 +372,312 @@ export function parseDestination(value: string | undefined, host: string | undef 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 // --------------------------------------------------------------------------- @@ -469,6 +805,85 @@ export function parseProppatch(body: Uint8Array): ProppatchRequest { 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. * @@ -553,7 +968,123 @@ export function encodeMultistatus(entries: readonly MultistatusEntry[]): string ); } -/** Encode an `` document carrying one §16 condition (RFC 4918 §14.5). */ -export function encodeErrorDocument(condition: string): string { - return xmlDocument({ name: "error", children: [{ name: condition }] }, { 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/session.ts b/src/webdav/session.ts index 6c4a864..8f5e1f6 100644 --- a/src/webdav/session.ts +++ b/src/webdav/session.ts @@ -10,39 +10,56 @@ * its own paths and is done with them before it answers. That is the same * reason `mountx/s3` has neither. * - * ## What "minimal" means here, exactly + * ## What this server implements, exactly * - * **Class 1 of RFC 4918, complete; class 2 absent.** `OPTIONS`, `HEAD`, `GET`, - * `PUT`, `DELETE`, `MKCOL`, `COPY`, `MOVE`, `PROPFIND` and `PROPPATCH` are - * implemented against the driver. `LOCK` and `UNLOCK` are not, the `DAV` header - * says `1, 3` rather than `1, 2, 3`, and the two methods answer `405` with an - * `Allow` listing what is really there — declared-or-inferred, never faked - * (`AGENTS.md`, invariant 5). + * **RFC 4918 classes 1, 2 and 3.** `OPTIONS`, `HEAD`, `GET`, `PUT`, `DELETE`, + * `MKCOL`, `COPY`, `MOVE`, `PROPFIND`, `PROPPATCH`, `LOCK` and `UNLOCK`, over + * one driver. * - * That is a decision with a visible cost, so it is written down rather than - * discovered: **macOS's `mount_webdav` mounts a class-1 share read-only**, and - * the Windows redirector is unhappy in its own ways. A client that speaks the - * protocol rather than the mount — `rclone`, `curl`, `cadaver`, a browser, most - * Linux clients under `davfs2` — reads and writes normally. Locking is the next - * piece of work, not an oversight; see `.agents/roadmap.md`. + * 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 other deliberate gaps, each because the driver interface has no answer - * for them rather than because they were forgotten: + * 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 everything, which is the * truthful answer for a server whose properties are all live and all derived. - * - **No conditional requests.** `If`, `If-Match`, `If-None-Match` and the two - * date forms are ignored rather than half-honoured. `mountx/s3` implements - * RFC 9110's four; doing the same here without `LOCK` would leave `If` — the - * one WebDAV adds, and the one that exists to carry lock tokens — as the - * conspicuous hole. They arrive together. + * - **No conditional requests.** `If-Match`, `If-None-Match` and the two date + * forms are ignored rather than half-honoured. `mountx/s3` implements + * RFC 9110's four over the same derived ETag; they arrive here next. * - **`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. + * * ## Symbolic links: followed for bytes, never walked * * WebDAV has no way to name a link, so a link is the resource it points at — @@ -61,10 +78,11 @@ * * ## The properties this server has * - * All live, all derived from one `stat`, and none of them stored: - * `creationdate`, `displayname`, `getcontentlength`, `getcontenttype`, - * `getetag`, `getlastmodified`, `resourcetype`, `supportedlock` (empty — see - * above) and `lockdiscovery` (empty, and always will be). RFC 4331's + * 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 @@ -106,22 +124,34 @@ import { READ_CHUNK_BYTES, RESOURCE_CONTENT_TYPE, } from "./constants.ts"; +import { DavLockTable, type DavLock, type DavLockTableOptions, type LockDepth } 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 IfList, type MultistatusEntry, type Propstat, type WebdavRequestHead, @@ -204,6 +234,21 @@ export interface WebdavSessionOptions { * 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. */ @@ -252,6 +297,16 @@ interface Failure { 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, @@ -265,6 +320,7 @@ export class WebdavSession { 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(); @@ -275,6 +331,8 @@ export class WebdavSession { 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"; } @@ -367,10 +425,16 @@ export class WebdavSession { case "PROPPATCH": { return await this.#proppatch(path, body); } + case "LOCK": { + return await this.#lock(head, path, body); + } + case "UNLOCK": { + return this.#unlock(head, path); + } default: { - /* Everything else, `LOCK` and `UNLOCK` included: the `Allow` header is - the honest list, and a client reading it learns this is a class-1 - server without having to parse the `DAV` header. */ + /* `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 } }); } } @@ -612,9 +676,13 @@ export class WebdavSession { } 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); @@ -764,9 +832,17 @@ export class WebdavSession { } 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" } }; @@ -911,10 +987,13 @@ export class WebdavSession { } const request = parsePropfind(await collectBody(body, this.#maxXmlBytes)); const stats = await this.#stat(path); + /* One `now` for the whole document: two resources described by one reply + must not report leases read off two different clocks. */ + const now = this.#now(); const entries: MultistatusEntry[] = [ { href: hrefOf(path, stats.isDirectory()), - propstat: await this.#propstats(path, stats, request), + propstat: await this.#propstats(path, stats, request, now), }, ]; if (depth === 1 && stats.isDirectory()) { @@ -926,7 +1005,7 @@ export class WebdavSession { } entries.push({ href: hrefOf(child, childStats.isDirectory()), - propstat: await this.#propstats(child, childStats, request), + propstat: await this.#propstats(child, childStats, request, now), }); } } @@ -952,13 +1031,14 @@ export class WebdavSession { 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); + const node = await this.#property(name, path, stats, now); if (node === undefined) { if (explicit) { missing.push({ name }); @@ -985,7 +1065,12 @@ export class WebdavSession { * 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): Promise { + async #property( + name: string, + path: string, + stats: StatsLike, + now: number, + ): Promise { const collection = stats.isDirectory(); switch (name) { case "creationdate": { @@ -1013,12 +1098,17 @@ export class WebdavSession { case "resourcetype": { return { name, children: collection ? [{ name: "collection" }] : [] }; } - case "supportedlock": + 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": { - /* Both empty, and both truthful: no lock type is supported and no lock - is ever held. Sending them at all is what tells a client it need not - ask. */ - return { name }; + /* 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": { @@ -1093,6 +1183,216 @@ export class WebdavSession { ); } + // ------------------------------------------------------------------------- + // 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, + ): Promise { + const info = parseLockInfo(await collectBody(body, this.#maxXmlBytes)); + const now = this.#now(); + const timeout = parseTimeout(head.headers["timeout"]); + if (info === undefined) { + return this.#refreshLock(head, path, timeout, now); + } + /* §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 as LockDepth, 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)); + await (await this.driver.open(path, "w", 0o666)).close(); + } + const grant = this.locks.create( + { + path, + collection, + depth: depth as LockDepth, + 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. + */ + #refreshLock( + head: WebdavRequestHead, + path: string, + timeout: number | "infinite" | undefined, + now: number, + ): WebdavResponse { + const lists = this.#ifLists(head); + if (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 submittedTokens(lists)) { + const lock = this.locks.find(token, now); + if (lock !== undefined && DavLockTable.inScope(lock, 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 // ------------------------------------------------------------------------- diff --git a/test/webdav/locks.test.ts b/test/webdav/locks.test.ts new file mode 100644 index 0000000..b750fa4 --- /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("discards every lock rooted in a subtree, and nothing rooted above it", () => { + const table = tableOf(); + const tree = granted(table, SHARED_TREE, START); + const file = granted(table, SHARED_FILE, START); + expect(table.discard("/notes/draft.txt", START).map((lock) => lock.token)).toEqual([ + file.token, + ]); + expect(table.all(START).map((lock) => lock.token)).toEqual([tree.token]); + expect(table.discard("/notes", START).map((lock) => lock.token)).toEqual([tree.token]); + expect(table.all(START)).toEqual([]); + }); + + 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/oracle.test.ts b/test/webdav/oracle.test.ts index aeeff7e..383eede 100644 --- a/test/webdav/oracle.test.ts +++ b/test/webdav/oracle.test.ts @@ -309,17 +309,18 @@ describe.skipIf(curl === undefined)("curl against mountx/webdav", () => { ); it( - "advertises class 1 and 3, and says so to an unauthenticated client too", + "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, 3"); + expect(headers.toLowerCase()).toContain("dav: 1, 2, 3"); expect(headers.toLowerCase()).toContain("ms-author-via: dav"); - // Class 2 is not advertised, and the method list says the same thing. - expect(headers).not.toContain("LOCK"); + // 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, diff --git a/test/webdav/protocol.test.ts b/test/webdav/protocol.test.ts index ccb24ae..cee7471 100644 --- a/test/webdav/protocol.test.ts +++ b/test/webdav/protocol.test.ts @@ -4,8 +4,9 @@ * Three kinds of fact live here, and they are checked against different * sources: * - * - **RFC 4918's grammars** — `Depth`, `Overwrite`, `Destination`, the two - * request bodies, and the two response documents. The section is named at the + * - **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 @@ -20,23 +21,35 @@ 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 { @@ -388,3 +401,269 @@ describe("encodeErrorDocument", () => { ); }); }); + +// --------------------------------------------------------------------------- +// 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 [ + "", + " ", + "(", + "()", + "", + "()", + ]) { + expect(parseIf(value, host), JSON.stringify(value)).toBeUndefined(); + } + }); + + it("submits every positive state token, once, and never a negated one", () => { + /* §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 index 877f336..bf7c837 100644 --- a/test/webdav/server.test.ts +++ b/test/webdav/server.test.ts @@ -228,7 +228,7 @@ describe("createWebdavServer: a client's session", () => { const options = await fetch(`${server.url}/`, { method: "OPTIONS" }); expect(options.status).toBe(200); - expect(options.headers.get("dav")).toBe("1, 3"); + expect(options.headers.get("dav")).toBe("1, 2, 3"); expect((await fetch(`${server.url}/notes`, { method: "MKCOL" })).status).toBe(201); diff --git a/test/webdav/session.test.ts b/test/webdav/session.test.ts index 5b4af88..d15cd64 100644 --- a/test/webdav/session.test.ts +++ b/test/webdav/session.test.ts @@ -103,12 +103,13 @@ beforeEach(async () => { // --------------------------------------------------------------------------- describe("OPTIONS", () => { - it("advertises class 1 and 3, and never class 2", async () => { + 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, 3"); + expect(reply.headers["dav"]).toBe("1, 2, 3"); expect(reply.headers["allow"]).toContain("PROPFIND"); - expect(reply.headers["allow"]).not.toContain("LOCK"); + expect(reply.headers["allow"]).toContain("LOCK"); + expect(reply.headers["allow"]).toContain("UNLOCK"); expect(reply.headers["ms-author-via"]).toBe("DAV"); }); @@ -121,7 +122,7 @@ describe("OPTIONS", () => { describe("an unimplemented method", () => { it("is 405 with an Allow that tells the truth", async () => { - for (const method of ["LOCK", "UNLOCK", "PATCH", "REPORT"]) { + 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"); @@ -728,6 +729,339 @@ describe("PROPPATCH", () => { }); }); +// --------------------------------------------------------------------------- +// 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 }); + await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); + expect(locking.locks.all(LOCK_START)).toHaveLength(2); + expect((await request(locking, "DELETE", "/dir/file.txt")).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(); + await lockOf(locking, "/dir/file.txt"); + const reply = await request(locking, "MOVE", "/dir/file.txt", { + headers: { destination: "/moved.txt" }, + }); + 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", { + headers: { destination: "/dir/other.txt" }, + }); + expect(reply.status).toBe(204); + expect(locking.locks.all(LOCK_START).map((lock) => lock.token)).toEqual([token]); + }); +}); + // --------------------------------------------------------------------------- // authentication // --------------------------------------------------------------------------- From 2028295f6a54145975c143faf554514b920e55fb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:33:40 +0000 Subject: [PATCH 04/13] feat(webdav): the If header, and the locks it unlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 4918 §10.4 evaluated and enforced. `#guard` runs once per request and keeps §10.4.1's two purposes apart: every state list is evaluated (a header that is wholly false is `412`), and every positive state token in it counts as submitted whether or not its list was true — which is what makes §10.4.8's `(Not )` idiom work. Every mutating method then consults it. `PUT`, `DELETE`, `MKCOL`, `PROPPATCH`, a `COPY` destination, both ends of a `MOVE`, and the empty resource a `LOCK` creates each answer `423` with `lock-token-submitted` naming the lock roots in the way (§7.5.2), and a lock on a *member* of a tree is a `207` carrying `423` for it (§9.6.1) with nothing deleted. Membership is checked against the parent as well as the resource, which is the only thing a depth-0 collection lock protects (§7.4). `GET`, `HEAD` and `PROPFIND` stay unprotected (§7) while still honouring `If`. RFC 9110's entity-tag list parser and its two comparison functions move to `src/http.ts` — the `If` header matches tags with the same code the S3 gateway does — and `src/s3/protocol.ts` re-exports `parseETagList`, `ETag` and `ETagList` under the names they had, so `mountx/s3`'s surface is unchanged and its 629 tests are untouched. `test/webdav/oracle.test.ts` drives the whole round trip through real curl: LOCK a null URL (201), PUT refused 423, PUT with `If` 204, UNLOCK. Co-Authored-By: Claude Opus 5 --- src/http.ts | 76 ++++++- src/s3/protocol.ts | 85 ++------ src/webdav/session.ts | 396 +++++++++++++++++++++++++++++++++--- test/webdav/oracle.test.ts | 72 +++++++ test/webdav/session.test.ts | 235 ++++++++++++++++++++- 5 files changed, 759 insertions(+), 105 deletions(-) diff --git a/src/http.ts b/src/http.ts index 1750bbe..775b0ef 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,5 +1,6 @@ /** - * The HTTP the two HTTP transports share: `HTTP-date`, `Range`, `ETag`. + * The HTTP the two HTTP transports share: `HTTP-date`, `Range`, `ETag` and the + * two entity-tag comparison functions. * * 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 @@ -265,6 +266,79 @@ export function parseRange(value: string | undefined, size: number): RangeSpec { 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}"`; diff --git a/src/s3/protocol.ts b/src/s3/protocol.ts index beb1bb6..0cb6d36 100644 --- a/src/s3/protocol.ts +++ b/src/s3/protocol.ts @@ -49,10 +49,13 @@ import { MULTIPART_PREFIX, } from "./constants.ts"; import { + etagMatchesStrongly, + etagMatchesWeakly, formatContentRange, formatETag, formatHttpDate, MAX_TIMESTAMP_MS, + parseETagList, parseHttpDate, } from "../http.ts"; import { normalizePath } from "../path.ts"; @@ -500,10 +503,12 @@ export function s3ErrorResponse(error: S3ErrorSpec, extra: S3ErrorExtra = {}): S // --------------------------------------------------------------------------- /* - * `HTTP-date`, the `Range` grammar and the `ETag` quoting are RFC 9110 rather - * than S3, and `mountx/webdav` answers the same three. 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. + * `HTTP-date`, the `Range` grammar, the `ETag` quoting and the entity-tag + * comparison functions 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. */ export { formatContentRange, @@ -512,8 +517,11 @@ export { formatIsoDate, formatUnsatisfiedRange, MAX_TIMESTAMP_MS, + parseETagList, parseHttpDate, parseRange, + type ETag, + type ETagList, type RangeSpec, } from "../http.ts"; @@ -1521,75 +1529,6 @@ export function routeRequest( // 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). - * - * 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. */ diff --git a/src/webdav/session.ts b/src/webdav/session.ts index 8f5e1f6..3eeb46c 100644 --- a/src/webdav/session.ts +++ b/src/webdav/session.ts @@ -34,8 +34,9 @@ * `403 cannot-modify-protected-property` for everything, which is the * truthful answer for a server whose properties are all live and all derived. * - **No conditional requests.** `If-Match`, `If-None-Match` and the two date - * forms are ignored rather than half-honoured. `mountx/s3` implements - * RFC 9110's four over the same derived ETag; they arrive here next. + * forms are ignored rather than half-honoured — RFC 4918's own `If` (§10.4) + * is answered, and RFC 9110's four are not. `mountx/s3` implements them over + * the same derived ETag; they arrive here next. * - **`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. @@ -60,6 +61,36 @@ * 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 — @@ -111,7 +142,14 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { createLoopback, type Loopback } from "../harness.ts"; -import { formatETag, formatHttpDate, formatIsoDate, parseRange } from "../http.ts"; +import { + etagMatchesWeakly, + formatETag, + formatHttpDate, + formatIsoDate, + parseETagList, + 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"; @@ -151,6 +189,7 @@ import { xmlBody, type DavFault, type Depth, + type IfCondition, type IfList, type MultistatusEntry, type Propstat, @@ -278,6 +317,70 @@ interface Failure { status: number; } +/** + * 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 // --------------------------------------------------------------------------- @@ -401,32 +504,38 @@ export class WebdavSession { 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); + return await this.#put(head, path, body, guard); } case "DELETE": { - return await this.#delete(head, path); + return await this.#delete(head, path, guard); } case "MKCOL": { - return await this.#mkcol(path, body); + return await this.#mkcol(path, body, guard); } case "COPY": case "MOVE": { - return await this.#copyOrMove(head, path, method === "MOVE"); + return await this.#copyOrMove(head, path, method === "MOVE", guard); } case "PROPFIND": { - return await this.#propfind(head, path, body); + return await this.#propfind(head, path, body, guard); } case "PROPPATCH": { - return await this.#proppatch(path, body); + return await this.#proppatch(path, body, guard); } case "LOCK": { - return await this.#lock(head, path, body); + return await this.#lock(head, path, body, guard); } case "UNLOCK": { return this.#unlock(head, path); @@ -584,6 +693,7 @@ export class WebdavSession { 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)" }); @@ -596,6 +706,10 @@ export class WebdavSession { 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 }); await this.#write(path, body); const stats = await this.#statOrAbsent(path); const headers: Record = { "content-length": "0" }; @@ -657,7 +771,7 @@ export class WebdavSession { * body — §9.6 is explicit that a `multistatus` must not be sent when * everything worked. */ - async #delete(head: WebdavRequestHead, path: string): Promise { + async #delete(head: WebdavRequestHead, path: string, guard: Guard): Promise { if (path === "/") { throw refuse(403, { message: "the root collection is the share itself" }); } @@ -674,6 +788,18 @@ export class WebdavSession { 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()); @@ -756,7 +882,11 @@ export class WebdavSession { * 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): Promise { + 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" }); @@ -765,6 +895,8 @@ export class WebdavSession { 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" } }; } @@ -787,7 +919,12 @@ export class WebdavSession { * `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): Promise { + 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) { @@ -816,6 +953,22 @@ export class WebdavSession { } 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" }); @@ -977,6 +1130,7 @@ export class WebdavSession { head: WebdavRequestHead, path: string, body: AsyncIterable, + guard: Guard, ): Promise { const depth = parseDepth(head.headers["depth"], "infinity"); if (depth === undefined) { @@ -987,9 +1141,10 @@ export class WebdavSession { } const request = parsePropfind(await collectBody(body, this.#maxXmlBytes)); const stats = await this.#stat(path); - /* One `now` for the whole document: two resources described by one reply - must not report leases read off two different clocks. */ - const now = this.#now(); + /* 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()), @@ -1162,9 +1317,15 @@ export class WebdavSession { * property it could have set alongside one it could not needs to see which * was which. */ - async #proppatch(path: string, body: AsyncIterable): Promise { + 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); const names = [...request.set, ...request.remove]; return xmlBody( 207, @@ -1183,6 +1344,179 @@ export class WebdavSession { ); } + // ------------------------------------------------------------------------- + // 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 "whatever + * the condition it expressed was found to be true" — so the tokens survive + * an evaluation the client did not need, which is what §10.4.8's + * `(Not )` idiom is for. + * + * @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 // ------------------------------------------------------------------------- @@ -1207,13 +1541,15 @@ export class WebdavSession { head: WebdavRequestHead, path: string, body: AsyncIterable, + guard: Guard, ): Promise { const info = parseLockInfo(await collectBody(body, this.#maxXmlBytes)); - const now = this.#now(); + const now = guard.now; const timeout = parseTimeout(head.headers["timeout"]); if (info === undefined) { - return this.#refreshLock(head, path, timeout, now); + 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"); @@ -1235,6 +1571,11 @@ export class WebdavSession { 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( @@ -1279,14 +1620,13 @@ export class WebdavSession { * response header: §9.10.2 says it "is not returned in the response for a * successful refresh", since no token was created. */ - #refreshLock( - head: WebdavRequestHead, + async #refreshLock( path: string, timeout: number | "infinite" | undefined, - now: number, - ): WebdavResponse { - const lists = this.#ifLists(head); - if (lists === 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 @@ -1294,9 +1634,13 @@ export class WebdavSession { 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 submittedTokens(lists)) { + 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. */ diff --git a/test/webdav/oracle.test.ts b/test/webdav/oracle.test.ts index 383eede..e45c2ee 100644 --- a/test/webdav/oracle.test.ts +++ b/test/webdav/oracle.test.ts @@ -308,6 +308,78 @@ describe.skipIf(curl === undefined)("curl against mountx/webdav", () => { 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 () => { diff --git a/test/webdav/session.test.ts b/test/webdav/session.test.ts index d15cd64..bc56695 100644 --- a/test/webdav/session.test.ts +++ b/test/webdav/session.test.ts @@ -1031,17 +1031,20 @@ describe("locks and the resources under them", () => { 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 }); - await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); + const token = await lockOf(locking, "/dir/file.txt", { body: SHARED_LOCKINFO }); expect(locking.locks.all(LOCK_START)).toHaveLength(2); - expect((await request(locking, "DELETE", "/dir/file.txt")).status).toBe(204); + 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(); - await lockOf(locking, "/dir/file.txt"); + const token = await lockOf(locking, "/dir/file.txt"); const reply = await request(locking, "MOVE", "/dir/file.txt", { - headers: { destination: "/moved.txt" }, + headers: { destination: "/moved.txt", if: `(<${token}>)` }, }); expect(reply.status).toBe(201); expect(locking.locks.all(LOCK_START)).toEqual([]); @@ -1055,13 +1058,235 @@ describe("locks and the resources under them", () => { 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", { - headers: { destination: "/dir/other.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); + }); +}); + // --------------------------------------------------------------------------- // authentication // --------------------------------------------------------------------------- From a1aee32bf9bdf2c243c83cea6b06467439426349 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:36:55 +0000 Subject: [PATCH 05/13] test(webdav): a real kernel mount, through davfs2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test/webdav/oracle.test.ts` drives rclone and curl at the protocol level; nothing had ever put a kernel in front of this server. `mount.davfs` does, over FUSE, and the workload is ordinary syscalls with no WebDAV vocabulary in it: a tree, seeded bytes read back and compared, a 1 MiB file, rename, unlink, rmdir, names carrying a space and non-ASCII, sizes and mtimes, HTTP Basic, and the errno cases. Both directions are checked on the driver's own side against a node-fs driver over a mkdtemp directory. Two facts about davfs2 shape it. It caches, so a driver-side check is a bounded poll and the read path is proved by seeding the driver *before* mounting — a read-back of what the mount just wrote never reaches the server. And teardown is `umount -i`: the `umount.davfs` helper unmounts and then waits for a daemon that a container with no reaping init never reaps. Gated on an in-file `davfsClientProbe()` — Linux, mount.davfs, /dev/fuse, and root, since davfs2 refuses an unprivileged caller without an /etc/fstab entry — so a plain `pnpm test` skips it with a reason. Class 1 turns out to cost nothing here: davfs2 reads `DAV: 1, 3`, warns, and mounts read-write anyway. Not one LOCK reaches the server. Co-Authored-By: Claude Opus 5 --- .agents/environment.md | 111 +++++++ .agents/testing.md | 10 + package.json | 3 +- test/webdav/mount.test.ts | 673 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 796 insertions(+), 1 deletion(-) create mode 100644 test/webdav/mount.test.ts diff --git a/.agents/environment.md b/.agents/environment.md index d495321..11153bf 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,106 @@ 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 + ``` + +- **A class-1 share is fully writable, with `use_locks` left on.** davfs2 sends + `OPTIONS` first, reads `DAV: 1, 3`, prints `mount.davfs: warning: the server +does not support locks` and mounts read-write anyway. **Not one `LOCK` reaches + the server** — verified by shadowing `session.handleRequest` and counting. So + the class-2 gap costs nothing here; macOS's `mount_webdav` is the client that + insists, and it is a different client. +- **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/testing.md b/.agents/testing.md index 4698eec..636413c 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -112,6 +112,16 @@ rclone`/`curl` 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, the same role rclone plays for the S3 gateway. **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. diff --git a/package.json b/package.json index 4809b5f..298647d 100644 --- a/package.json +++ b/package.json @@ -81,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/test/webdav/mount.test.ts b/test/webdav/mount.test.ts new file mode 100644 index 0000000..f9fddd7 --- /dev/null +++ b/test/webdav/mount.test.ts @@ -0,0 +1,673 @@ +/** + * 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 class 1 turns out to cost here: nothing + * + * `mount.davfs` sends `OPTIONS` first, reads `DAV: 1, 3`, prints `the server + * does not support locks` and mounts **read-write anyway** — witnessed, and the + * reason the configuration below leaves `use_locks` at its default rather than + * turning it off. Not one `LOCK` reaches the server across this whole suite. + * macOS's `mount_webdav` is the client that insists on class 2, and it is not + * this one. + * + * ## `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. + * The point of this suite is that a class-1 share is writable by a real client + * *without* being told to stop asking for locks. + */ +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("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"); + }); +}); From 28ae09a9d7831d0cebf48c628f571fec7d764ef4 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:37:37 +0000 Subject: [PATCH 06/13] feat(webdav): RFC 9110's conditional requests on GET, HEAD and PUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `If-Match`, `If-None-Match`, `If-Modified-Since` and `If-Unmodified- Since`, evaluated in §13.2.2's order and before the `Range` — a `304` and a `412` are answers about the whole representation, so a range evaluated first would answer `206` to a request whose precondition failed. `304` carries the validators and no content, not even a length (§15.4.5). The rules were `src/s3/protocol.ts`'s and are transport-neutral, so they move to `src/http.ts` beside RFC 9110's dates, `Range` and `ETag` — the same treatment the first commit on this branch gave those. The one S3-shaped part was the header lookup: SigV4 signs headers as they were sent, so that transport keeps a list and cannot hand over the record WebDAV already has. `evaluateConditionals` therefore takes a plain lowercase record, and `src/s3/protocol.ts` keeps a wrapper of the same name and signature that joins its repeated list-based fields (§5.3). `parseETagList`, `ETag` and `ETagList` are re-exported unchanged; `mountx/s3`'s surface and its 629 tests are untouched. What the S3 gateway never had to answer is a `PUT` to a URL with no representation, and §13.1 decides it per header: `If-Match` on nothing is `412`, `If-None-Match` passes — which is where `If-None-Match: *` means create-only — and the two date forms are ignored. `423` still comes before `412`: a lock is a fact the client must resolve first. Co-Authored-By: Claude Opus 5 --- src/http.ts | 102 ++++++++++++++++++++++++- src/s3/protocol.ts | 107 ++++++++------------------- src/webdav/session.ts | 69 ++++++++++++++++- test/webdav/session.test.ts | 143 ++++++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 84 deletions(-) diff --git a/src/http.ts b/src/http.ts index 775b0ef..93682c7 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,6 +1,7 @@ /** - * The HTTP the two HTTP transports share: `HTTP-date`, `Range`, `ETag` and the - * two entity-tag comparison functions. + * 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 @@ -344,6 +345,103 @@ 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}`; diff --git a/src/s3/protocol.ts b/src/s3/protocol.ts index 0cb6d36..05f48c3 100644 --- a/src/s3/protocol.ts +++ b/src/s3/protocol.ts @@ -49,14 +49,13 @@ import { MULTIPART_PREFIX, } from "./constants.ts"; import { - etagMatchesStrongly, - etagMatchesWeakly, + evaluateConditionals as evaluateHttpConditionals, formatContentRange, formatETag, formatHttpDate, MAX_TIMESTAMP_MS, - parseETagList, - parseHttpDate, + type ConditionalResult, + type ConditionalTarget, } from "../http.ts"; import { normalizePath } from "../path.ts"; import type { HeaderEntry, QueryEntry, SigV4RefusalReason } from "./sigv4.ts"; @@ -503,12 +502,14 @@ export function s3ErrorResponse(error: S3ErrorSpec, extra: S3ErrorExtra = {}): S // --------------------------------------------------------------------------- /* - * `HTTP-date`, the `Range` grammar, the `ETag` quoting and the entity-tag - * comparison functions 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. + * `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 { formatContentRange, @@ -520,6 +521,8 @@ export { parseETagList, parseHttpDate, parseRange, + type ConditionalResult, + type ConditionalTarget, type ETag, type ETagList, type RangeSpec, @@ -1529,82 +1532,30 @@ export function routeRequest( // conditional requests // --------------------------------------------------------------------------- -/** 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`. + * Evaluate the four conditional headers in RFC 9110 §13.2.2's order. * - * `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, + ); } // --------------------------------------------------------------------------- diff --git a/src/webdav/session.ts b/src/webdav/session.ts index 3eeb46c..a6b0a2a 100644 --- a/src/webdav/session.ts +++ b/src/webdav/session.ts @@ -33,10 +33,13 @@ * that would then show up in every listing. `PROPPATCH` therefore answers * `403 cannot-modify-protected-property` for everything, which is the * truthful answer for a server whose properties are all live and all derived. - * - **No conditional requests.** `If-Match`, `If-None-Match` and the two date - * forms are ignored rather than half-honoured — RFC 4918's own `If` (§10.4) - * is answered, and RFC 9110's four are not. `mountx/s3` implements them over - * the same derived ETag; they arrive here next. + * - **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. @@ -144,6 +147,7 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { createLoopback, type Loopback } from "../harness.ts"; import { etagMatchesWeakly, + evaluateConditionals, formatETag, formatHttpDate, formatIsoDate, @@ -636,6 +640,17 @@ export class WebdavSession { 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") { @@ -662,6 +677,46 @@ export class WebdavSession { 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 { @@ -710,6 +765,12 @@ export class WebdavSession { 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" }; diff --git a/test/webdav/session.test.ts b/test/webdav/session.test.ts index bc56695..c1c0f34 100644 --- a/test/webdav/session.test.ts +++ b/test/webdav/session.test.ts @@ -1287,6 +1287,149 @@ describe("what a write lock protects", () => { }); }); +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- From 85e8080b3ba5dc36896eba4e31bd716bde4a202c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:40:41 +0000 Subject: [PATCH 07/13] feat(webdav): PROPPATCH stores the one property it can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getlastmodified` → `driver.utimes()`, on a driver declaring the `times` capability, answered `200` in its own propstat. Everything else stays `403 cannot-modify-protected-property`: the rest of this server's properties are live and derived, and a dead one needs a store the driver interface does not have — a sidecar file would show up in every listing. Two refusals that are not that one. A value that is not an `HTTP-date` is `409`, which §9.2.1 defines as "the client has provided a value whose semantics are not appropriate for the property", and it carries no §16 condition because there is none for it. A driver without `times` gets `403` again: the capability is declared-or-inferred and never faked, so on such a driver the property really is protected, and answering `200` would be storing nothing. `PROPPATCH` is also atomic now, which it could not observably be while every instruction failed: §9.2 requires that instructions "either all be executed or none executed", so one failure makes every settable property `424 Failed Dependency` (§9.2.1) and nothing is written. Instructions are evaluated in document order, which the same section makes normative, and `parseProppatch` carries each `` value alongside its name. Co-Authored-By: Claude Opus 5 --- src/webdav/protocol.ts | 42 ++++++++--- src/webdav/session.ts | 137 +++++++++++++++++++++++++++++++---- test/webdav/protocol.test.ts | 4 +- test/webdav/session.test.ts | 80 +++++++++++++++++++- 4 files changed, 235 insertions(+), 28 deletions(-) diff --git a/src/webdav/protocol.ts b/src/webdav/protocol.ts index 42e79d1..75c0635 100644 --- a/src/webdav/protocol.ts +++ b/src/webdav/protocol.ts @@ -762,17 +762,34 @@ export function parsePropfind(body: Uint8Array): PropfindRequest { 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 even though this server writes neither: the reply has to - * name **every** property the request did, each with its own status, and a + * 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 under ``, in request order. */ - set: string[]; + /** Property names and values under ``, in request order. */ + set: ProppatchSet[]; /** Property names under ``, in request order. */ remove: string[]; } @@ -784,17 +801,22 @@ export interface ProppatchRequest { */ export function parseProppatch(body: Uint8Array): ProppatchRequest { const root = parseDocument(body, "propertyupdate"); - const set: string[] = []; + const set: ProppatchSet[] = []; const remove: string[] = []; for (const child of root.children) { - const into = child.name === "set" ? set : child.name === "remove" ? remove : undefined; - if (into === undefined) { + const setting = child.name === "set"; + if (!setting && child.name !== "remove") { continue; } for (const prop of child.children) { - if (prop.name === "prop") { - for (const property of prop.children) { - into.push(property.name); + 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); } } } diff --git a/src/webdav/session.ts b/src/webdav/session.ts index a6b0a2a..854c844 100644 --- a/src/webdav/session.ts +++ b/src/webdav/session.ts @@ -31,8 +31,11 @@ * - **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 everything, which is the - * truthful answer for a server whose properties are all live and all derived. + * `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 @@ -152,6 +155,7 @@ import { formatHttpDate, formatIsoDate, parseETagList, + parseHttpDate, parseRange, } from "../http.ts"; import { basename, dirname, isPathInside, joinPath } from "../path.ts"; @@ -197,6 +201,7 @@ import { type IfList, type MultistatusEntry, type Propstat, + type ProppatchSet, type WebdavRequestHead, type WebdavResponse, } from "./protocol.ts"; @@ -321,6 +326,52 @@ interface Failure { 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). @@ -1387,24 +1438,84 @@ export class WebdavSession { const stats = await this.#stat(path); // §7's list of what a write lock covers names PROPPATCH explicitly. this.#requireWritable(path, guard); - const names = [...request.set, ...request.remove]; + /* 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: [ - { - status: 403, - props: names.map((name) => ({ name })), - condition: "cannot-modify-protected-property", - }, - ], - }, + { 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 // ------------------------------------------------------------------------- diff --git a/test/webdav/protocol.test.ts b/test/webdav/protocol.test.ts index cee7471..aebd1e1 100644 --- a/test/webdav/protocol.test.ts +++ b/test/webdav/protocol.test.ts @@ -259,7 +259,7 @@ describe("parseProppatch", () => { ``, ), ), - ).toEqual({ set: ["displayname"], remove: ["mine"] }); + ).toEqual({ set: [{ name: "displayname", text: "x" }], remove: ["mine"] }); }); it("ignores a child that is neither set nor remove", () => { @@ -270,7 +270,7 @@ describe("parseProppatch", () => { ``, ), ), - ).toEqual({ set: ["displayname"], remove: [] }); + ).toEqual({ set: [{ name: "displayname", text: "" }], remove: [] }); }); it("refuses a body naming nothing", () => { diff --git a/test/webdav/session.test.ts b/test/webdav/session.test.ts index c1c0f34..c41e534 100644 --- a/test/webdav/session.test.ts +++ b/test/webdav/session.test.ts @@ -706,17 +706,17 @@ describe("PROPFIND", () => { // --------------------------------------------------------------------------- describe("PROPPATCH", () => { - it("names every property with 403 and the condition that explains it", async () => { + 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` + + `x` + `` + ``, }); expect(reply.status).toBe(207); expect(statuses(reply.text)).toEqual([403]); - expect(reply.text).toContain(""); + expect(reply.text).toContain(""); expect(reply.text).toContain(""); expect(reply.text).toContain(""); }); @@ -727,6 +727,80 @@ describe("PROPPATCH", () => { }); 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]); + }); }); // --------------------------------------------------------------------------- From 9d202c126fe94326b4787cf16a18c5050f64a7eb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:44:41 +0000 Subject: [PATCH 08/13] docs(webdav): class 2, the If header and the conditionals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.agents/roadmap.md` loses the three entries this branch closed — locking, conditional requests, and the settable half of `PROPPATCH` — and keeps what is still open, restated in the present tense as the constraint the remaining work runs into: dead properties, the namespace the XML parser drops, and the six methods RFC 9110's conditionals are not honoured on. The Windows entry stops waiting for class-2 locking and starts saying what is untested instead. `docs/2.transports/6.webdav.md` gets a Locking section (scopes, tokens, leases, the locked empty resource, why a lock never follows its resource, and the three refusals `If` produces) and a Conditional requests section, and its mounting note now separates what is verified here from what is only predicted: macOS's `mount_webdav` and the Windows redirector should write to a class-2 share, and neither has been run against this one, because this machine is Linux. Co-Authored-By: Claude Opus 5 --- .agents/architecture.md | 39 ++++---- .agents/invariants.md | 7 +- .agents/roadmap.md | 53 +++++------ .agents/testing.md | 30 ++++--- docs/2.transports/0.index.md | 4 +- docs/2.transports/6.webdav.md | 161 +++++++++++++++++++++++++--------- 6 files changed, 190 insertions(+), 104 deletions(-) diff --git a/.agents/architecture.md b/.agents/architecture.md index 7846b56..8b66100 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -27,17 +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` | -| `http.ts` | RFC 9110's `HTTP-date`, `Range` and `ETag` quoting — the one copy, shared by the two HTTP transports; `mountx/s3` re-exports every symbol under its own name | -| `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/`) @@ -140,8 +140,9 @@ and RFC 4331 for the quota pair. | `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` answers `403 cannot-modify-protected-property` — a driver has nowhere -to keep one), no conditional requests yet, and no lock-null +(`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) @@ -231,10 +232,14 @@ The facts no single file's header can own. 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` and the `Content-Range`/`ETag` spellings, all - RFC 9110. It was `src/s3/protocol.ts`'s until WebDAV needed the same three; - `s3/protocol.ts` re-exports every symbol under its old name, so `mountx/s3`'s - surface never moved. + `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 and WebDAV are anywhere. macOS gets NFS by necessity — macFUSE is a third-party kext with its own dialect, so `src/fuse/` cannot serve diff --git a/.agents/invariants.md b/.agents/invariants.md index 68ce512..b877989 100644 --- a/.agents/invariants.md +++ b/.agents/invariants.md @@ -50,8 +50,11 @@ no cast. 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. The same argument covers a wire format two transports share: RFC 9110's `HTTP-date`, `Range` and `ETag` -spellings live in `src/http.ts`, which `src/s3/protocol.ts` re-exports under its own -names and `src/webdav/` imports directly — one transcription, two HTTP transports. +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 diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 80091b1..9227220 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -243,18 +243,6 @@ area whose code it changes, not the area that motivated it. ## WebDAV -- **No `LOCK`/`UNLOCK`, so the share is class 1.** This is the one gap with a - visible cost rather than a theoretical one: macOS's `mount_webdav` mounts a - class-1 share **read-only**, and the Windows redirector has its own - objections, so the transport that exists to be the unprivileged mount path - cannot yet be an unprivileged _writable_ mount path on either. What it needs - is a lock table (tokens, timeouts, depth-0 and depth-infinity scope) plus the - `If` header, which is the other half — a request proves it holds a lock by - carrying the token there, and implementing `If` without locks would be - answering a question nothing can ask. `src/9p/locks.ts` is the nearest - precedent for the table, though the semantics are not the same: 9P's are POSIX - byte ranges owned by a client/proc pair, WebDAV's are whole-resource (or - whole-subtree) and owned by a token the server minted. - **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 @@ -262,20 +250,21 @@ area whose code it changes, not the area that motivated it. 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 conditional requests.** `If-Match`, `If-None-Match`, `If-Modified-Since` - and `If-Unmodified-Since` are ignored rather than half-honoured. `mountx/s3` - implements RFC 9110's four over the same derived ETag, so the codec is - written; what is deliberate is the _timing_ — doing these without `If` would - leave the one header WebDAV adds as the conspicuous hole, so they arrive with - the locking work above. -- **`PROPPATCH` cannot store anything.** Every property is refused with `403 -cannot-modify-protected-property`, which is truthful for a server whose - properties are all live, and is also what makes a client that sets - `Win32LastModifiedTime` (Finder, Explorer) report a failure it did not expect. - The cheap half is `getlastmodified` → `driver.utimes()` for a driver - declaring `times`; the expensive half is dead properties, which need a store - the driver interface does not have — a sidecar file would show up in every - listing. +- **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 @@ -302,8 +291,10 @@ cannot-modify-protected-property`, which is truthful for a server whose if someone actually needs the volume to appear where Finder puts one. - **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 — but - nothing here has been run on Windows, and the redirector wants class-2 - locking before it will write (see the WebDAV section). Windows also has no - `mount(8)`, so it stays out of the NFS transport's platform switch either - way. + 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 4698eec..467de41 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -98,20 +98,26 @@ 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 two - request grammars, the `multistatus`/`error` 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), `session.test.ts` (in-process - against the memory driver, no sockets: RFC 4918's per-method semantics, 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 +- `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, the same role rclone plays for the S3 gateway. **No - conformance column yet** — see Known gaps. + 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/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. diff --git a/docs/2.transports/0.index.md b/docs/2.transports/0.index.md index 56133ce..c96e994 100644 --- a/docs/2.transports/0.index.md +++ b/docs/2.transports/0.index.md @@ -21,7 +21,7 @@ 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 class 1"] + dav["mountx/webdav
RFC 4918 classes 1, 2, 3"] kdav["any WebDAV client
over HTTP"] driver --> auto driver --> s3 @@ -120,4 +120,4 @@ Between the two: **S3** is for object-storage clients and has no directories, no - [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 class 1, and the unprivileged way to get a mountpoint anyway. +- [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 index 6958e11..0019671 100644 --- a/docs/2.transports/6.webdav.md +++ b/docs/2.transports/6.webdav.md @@ -7,7 +7,7 @@ title: 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) **class 1** — every method the specification defines except `LOCK` and `UNLOCK` — 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). +`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"; @@ -60,12 +60,12 @@ A WebDAV share is mountable without any of this package's mount transports, and # Linux sudo mount -t davfs http://127.0.0.1:PORT /mnt/point -# macOS (read-only here — see the note on locking below) +# macOS mount_webdav -S http://127.0.0.1:PORT /Volumes/mountx ``` -::warning -**A class-1 share is read-only to macOS's `mount_webdav`**, and the Windows redirector has its own objections. Both want class 2 — WebDAV locking — before they will write, and locking is [not implemented](#no-locking-yet). Every client that speaks the protocol directly rather than through a kernel mount (`rclone`, `curl`, `cadaver`, a browser, `davfs2`) reads _and_ writes normally. +::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 @@ -81,19 +81,21 @@ Basic sends a recoverable password on every request, which is WebDAV's own defau ### What each method answers -| method | | -| ------------ | ----------------------------------------------------------------------------------------------------------- | -| `OPTIONS` | `DAV: 1, 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` with `403 cannot-modify-protected-property` per property — see [properties](#properties-are-all-live) | - -Anything else — `LOCK`, `UNLOCK`, `REPORT`, `PATCH` — is `405` with an `Allow` that lists what is really there. +| 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: @@ -102,10 +104,14 @@ Two of those differ from what a plain HTTP server would answer, and both are RFC ### Properties are all live -Every property is derived from a single `stat`, and none are stored: `creationdate`, `displayname`, `getcontentlength`, `getcontenttype`, `getetag`, `getlastmodified`, `resourcetype`, plus an empty `supportedlock` and `lockdiscovery` (truthfully empty — there are no locks). 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. +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 @@ -128,11 +134,81 @@ WebDAV has no way to name a link, so a link is the resource it points at: `GET`, 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. -## No locking yet +## 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. -`LOCK` and `UNLOCK` are absent, the `DAV` header says `1, 3` rather than `1, 2, 3`, and the two methods answer `405`. That is the [capabilities rule](/reference/capabilities) applied to a protocol header: an unmet capability answers honestly rather than pretending. +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. -The cost is real and worth stating plainly — it is what makes macOS's `mount_webdav` mount read-only, and it is why the `If` header (which exists to carry lock tokens) is not implemented either. Locking is the next piece of work on this transport, not an oversight. +`DELETE`, `COPY` and `MOVE` ignore all four. The header a WebDAV client reaches for on those is `If`, which _is_ enforced. ## `createWebdavServer(driver, options?)` @@ -190,6 +266,7 @@ One HTTP request in, one WebDAV reply out, with no socket anywhere — the same ```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 ``` @@ -198,25 +275,28 @@ await session.handleRequest(head, body?); // → WebdavResponse; never rejects ### `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` document | -| `readChunkBytes` | 128 KiB | bytes per positional read while streaming a `GET` | -| `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 | +| 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`, `multistatus` and `error` | -| `session.ts` | `WebdavSession` — the method semantics, over one driver | -| `server.ts` | the socket, and the only file that imports `node:http` | +| 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. @@ -224,9 +304,10 @@ Documents go out with `DAV:` as the **default** namespace — ` Date: Fri, 31 Jul 2026 22:47:05 +0000 Subject: [PATCH 09/13] refactor(webdav): drop the lock-table method nothing calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DavLockTable.discard` bulk-deleted a subtree's locks, which read like §6.1 point 8 and is not how the session applies it: `#discardUnmapped` walks `within(path)` and checks each root, because at an overwritten `MOVE` destination the root was remapped by the same request (§7.6) and only the members that were not recreated are gone. One rule, one code path, and the table keeps the query the rule is built on. Co-Authored-By: Claude Opus 5 --- src/webdav/locks.ts | 28 ++++++---------------------- src/webdav/session.ts | 30 +++++++++++++++++------------- test/webdav/locks.test.ts | 12 ++++++------ test/webdav/protocol.test.ts | 12 ++++++++++++ 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/webdav/locks.ts b/src/webdav/locks.ts index 1a2d723..9f0a41b 100644 --- a/src/webdav/locks.ts +++ b/src/webdav/locks.ts @@ -63,11 +63,12 @@ * 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 - * ({@link DavLockTable.discard}), 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. + * 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 * @@ -382,23 +383,6 @@ export class DavLockTable { return this.#locks.delete(token); } - /** - * Delete every lock rooted at `path` or under it, and answer which went. - * - * §6.1 point 8, and it is the *only* thing that happens to a lock when its - * resource moves or goes away: "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". Called by `DELETE` and by the source side of a `MOVE`; a lock - * rooted *above* the path is untouched, because its own root is still mapped. - */ - discard(path: string, now: number): DavLock[] { - const doomed = this.within(path, now); - for (const lock of doomed) { - this.#locks.delete(lock.token); - } - return doomed; - } - /** * Seconds left on a lease, for the `timeout` element (§14.29: "the number of * seconds remaining before a lock expires"). diff --git a/src/webdav/session.ts b/src/webdav/session.ts index 854c844..f216268 100644 --- a/src/webdav/session.ts +++ b/src/webdav/session.ts @@ -1418,16 +1418,20 @@ export class WebdavSession { // ------------------------------------------------------------------------- /** - * Refuse to write properties, in the form §9.2 requires. + * Write properties, in the form §9.2 requires — which here means writing one + * and refusing the rest. * - * Every property in the request is named in the reply with `403` and the - * `cannot-modify-protected-property` condition (§16), because every property - * this server has is a live one derived from the driver's own metadata and - * there is nowhere to put a dead one (see the module docs). The status is a - * `207` rather than a plain `403`: §9.2 requires the per-property form - * whenever the request named more than nothing, and a client that sent one - * property it could have set alongside one it could not needs to see which - * was which. + * 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, @@ -1528,10 +1532,10 @@ export class WebdavSession { * * 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 "whatever - * the condition it expressed was found to be true" — so the tokens survive - * an evaluation the client did not need, which is what §10.4.8's - * `(Not )` idiom is for. + * 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. diff --git a/test/webdav/locks.test.ts b/test/webdav/locks.test.ts index b750fa4..7bba9e0 100644 --- a/test/webdav/locks.test.ts +++ b/test/webdav/locks.test.ts @@ -234,16 +234,16 @@ describe("lifecycle", () => { expect(table.all(START)).toEqual([]); }); - it("discards every lock rooted in a subtree, and nothing rooted above it", () => { + 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.discard("/notes/draft.txt", START).map((lock) => lock.token)).toEqual([ - file.token, - ]); + 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.discard("/notes", START).map((lock) => lock.token)).toEqual([tree.token]); - expect(table.all(START)).toEqual([]); + expect(table.within("/notes", START).map((lock) => lock.token)).toEqual([tree.token]); }); it("refuses a lock past the cap, counting only the live ones", () => { diff --git a/test/webdav/protocol.test.ts b/test/webdav/protocol.test.ts index aebd1e1..1451e8f 100644 --- a/test/webdav/protocol.test.ts +++ b/test/webdav/protocol.test.ts @@ -262,6 +262,17 @@ describe("parseProppatch", () => { ).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( @@ -524,6 +535,7 @@ describe("parseIf", () => { "(garbage)", `(["unterminated)`, ")", + " Date: Fri, 31 Jul 2026 22:48:07 +0000 Subject: [PATCH 10/13] refactor(webdav): let the Depth narrowing stand on its own `LOCK` refuses `Depth: 1` before it reaches the lock table, so what is left really is `LockDepth`; the two casts were saying so a second time. Co-Authored-By: Claude Opus 5 --- src/webdav/session.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/webdav/session.ts b/src/webdav/session.ts index f216268..e07699c 100644 --- a/src/webdav/session.ts +++ b/src/webdav/session.ts @@ -170,7 +170,7 @@ import { READ_CHUNK_BYTES, RESOURCE_CONTENT_TYPE, } from "./constants.ts"; -import { DavLockTable, type DavLock, type DavLockTableOptions, type LockDepth } from "./locks.ts"; +import { DavLockTable, type DavLock, type DavLockTableOptions } from "./locks.ts"; import { collectBody, encodeLockResponse, @@ -1737,7 +1737,7 @@ export class WebdavSession { 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 as LockDepth, info.exclusive, now); + const blocking = this.locks.conflict(path, depth, info.exclusive, now); if (blocking !== undefined) { throw this.#conflictingLock(blocking); } @@ -1758,7 +1758,7 @@ export class WebdavSession { { path, collection, - depth: depth as LockDepth, + depth, exclusive: info.exclusive, owner: info.owner, timeoutSeconds: timeout, From 0044fe4a1534001780d0fc3e24bdfad3ee14e96b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:52:12 +0000 Subject: [PATCH 11/13] fix(webdav): a COPY of a link to an ancestor never terminated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#copyTree` refuses a symbolic link to a collection it meets *inside* the tree, for the reason its comment gives: the walk would revisit a subtree it is still writing into and create the next level every pass. At the **top** of the tree the same link was followed, because the source is `stat`ed rather than `lstat`ed and a link to a collection answers `isDirectory()`. That is the end where it matters more. The destination-inside-source refusal compares paths as text, so `COPY /link → /dst` with `/link → /` passes it — the two names are unrelated — and the copy then runs until the driver runs out of something: on the memory driver, never. One request, unbounded work. A link to a collection is now `403` at the top of the tree too. A link to a *file* is still followed and its bytes copied (that cannot recur), and `MOVE` is unaffected: it renames the link itself and walks nothing. Co-Authored-By: Claude Opus 5 --- src/webdav/session.ts | 17 ++++++++++++++++- test/webdav/session.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/webdav/session.ts b/src/webdav/session.ts index e07699c..aa41f4c 100644 --- a/src/webdav/session.ts +++ b/src/webdav/session.ts @@ -111,7 +111,11 @@ * 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. + * 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 * @@ -1057,6 +1061,17 @@ export class WebdavSession { 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 diff --git a/test/webdav/session.test.ts b/test/webdav/session.test.ts index c41e534..5832fc1 100644 --- a/test/webdav/session.test.ts +++ b/test/webdav/session.test.ts @@ -510,6 +510,38 @@ describe("COPY", () => { }); }); +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 }, From 6d8c9a66b4bbba24ea5ea878ca04ce1c8d3a0161 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:56:36 +0000 Subject: [PATCH 12/13] test(webdav): assert the kernel client takes the lock it is offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class 2 landed under this suite, and the header's "not one LOCK reaches the server" stopped being true the moment it did. Measured both sides rather than edited the sentence: against `DAV: 1, 3` davfs2 warns and writes without a lock, against `DAV: 1, 2, 3` it takes one per write and releases it — `OPTIONS 1, PROPFIND 2, HEAD 2, LOCK 2, PUT 2, UNLOCK 2` for one write and one copy. The second is now asserted rather than described, which makes this the only test in the package where LOCK, the If header and UNLOCK are driven by a real kernel client instead of by a `fetch` this repository wrote. Checked it has teeth: advertising class 1 again fails this case and no other. The class-1 observation is kept in `.agents/environment.md`, where a fact about a client belongs — it is the evidence behind "macOS's mount_webdav is the client that insists on class 2, and davfs2 is not". --- .agents/environment.md | 20 +++++++++---- test/webdav/mount.test.ts | 62 ++++++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/.agents/environment.md b/.agents/environment.md index 11153bf..2ed71e3 100644 --- a/.agents/environment.md +++ b/.agents/environment.md @@ -449,12 +449,20 @@ test:webdav:mount` passes**: 5 passed, 2 skipped, ~0.2 s of tests. mount.davfs http://127.0.0.1:PORT/ /mnt/point -o conf=…/davfs2.conf,rw,uid=0,gid=0 ``` -- **A class-1 share is fully writable, with `use_locks` left on.** davfs2 sends - `OPTIONS` first, reads `DAV: 1, 3`, prints `mount.davfs: warning: the server -does not support locks` and mounts read-write anyway. **Not one `LOCK` reaches - the server** — verified by shadowing `session.handleRequest` and counting. So - the class-2 gap costs nothing here; macOS's `mount_webdav` is the client that - insists, and it is a different client. +- **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 diff --git a/test/webdav/mount.test.ts b/test/webdav/mount.test.ts index f9fddd7..3dca058 100644 --- a/test/webdav/mount.test.ts +++ b/test/webdav/mount.test.ts @@ -35,14 +35,26 @@ * 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 class 1 turns out to cost here: nothing + * ## What the `DAV` header buys, measured twice * - * `mount.davfs` sends `OPTIONS` first, reads `DAV: 1, 3`, prints `the server - * does not support locks` and mounts **read-write anyway** — witnessed, and the - * reason the configuration below leaves `use_locks` at its default rather than - * turning it off. Not one `LOCK` reaches the server across this whole suite. - * macOS's `mount_webdav` is the client that insists on class 2, and it is not - * this one. + * `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 * @@ -238,9 +250,11 @@ const PASSWORD = "a pass:word"; * 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. - * The point of this suite is that a class-1 share is writable by a real client - * *without* being told to stop asking for locks. + * **`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 [ @@ -570,6 +584,34 @@ describe.skipIf(!probe.usable)("a real WebDAV mount", () => { 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 From 1088289756f2ecb9bd56f569c26899e4a61ef470 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 1 Aug 2026 10:09:33 +0000 Subject: [PATCH 13/13] fix(test): skip the rclone oracle setup when rclone is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file-level beforeAll built the RCLONE_CONFIG_MX_* environment unconditionally, so a host with curl and no rclone — which is every CI runner — ran `rclone obscure` with an undefined binary and failed the whole file in setup, taking the curl half down with it. Co-Authored-By: Claude Opus 5 --- test/webdav/oracle.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/webdav/oracle.test.ts b/test/webdav/oracle.test.ts index e45c2ee..7995e9a 100644 --- a/test/webdav/oracle.test.ts +++ b/test/webdav/oracle.test.ts @@ -144,6 +144,11 @@ beforeAll(async () => { 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: "",