Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions .agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,17 @@ Deviations are noted per area below.

## Core (`src/`)

| File | What |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `types.ts` | `FsDriver`, `FsCapabilities`, `StatsLike`/`DirentLike`/`FileHandleLike`, and the `mountx.*` namespace — **two live members**, `mknod` and `utimens`, no xattr |
| `errors.ts` | `ERRNO_CODES` (Linux), `fsError()` (byte-identical to `node:fs`'s), `errnoOf()` — the one errno table in the repo |
| `path.ts` | absolute POSIX helpers, `..` clamps at root; canonical paths early-return, `resolvePath()` returns `{ path, segments }` |
| `harness.ts` | `createLoopback(driver)` — normalize, fill gaps with `ENOSYS`, resolve capabilities. The method table is fixed **at construction** |
| `lock.ts` | `PathLock` — `RENAME` takes it, `READ`/`WRITE` run outside it |
| `subtree.ts` | `remapSubtree()` — the rename rewrite; internal, deliberately not in the public `path.ts` |
| `ownership.ts` | who a new entry belongs to: `inode_init_owner()`'s set-gid rule, plus the `lchown`/`chmod` that applies it. Internal; used by the two NFS sessions' `#claim` |
| `auto.ts` | `mountx/auto` — probe, then FUSE → 9P → NFS, each behind `await import()` |
| File | What |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `types.ts` | `FsDriver`, `FsCapabilities`, `StatsLike`/`DirentLike`/`FileHandleLike`, and the `mountx.*` namespace — **two live members**, `mknod` and `utimens`, no xattr |
| `errors.ts` | `ERRNO_CODES` (Linux), `fsError()` (byte-identical to `node:fs`'s), `errnoOf()` — the one errno table in the repo |
| `path.ts` | absolute POSIX helpers, `..` clamps at root; canonical paths early-return, `resolvePath()` returns `{ path, segments }` |
| `harness.ts` | `createLoopback(driver)` — normalize, fill gaps with `ENOSYS`, resolve capabilities. The method table is fixed **at construction** |
| `lock.ts` | `PathLock` — `RENAME` takes it, `READ`/`WRITE` run outside it |
| `subtree.ts` | `remapSubtree()` — the rename rewrite; internal, deliberately not in the public `path.ts` |
| `ownership.ts` | who a new entry belongs to: `inode_init_owner()`'s set-gid rule, plus the `lchown`/`chmod` that applies it. Internal; used by the two NFS sessions' `#claim` |
| `http.ts` | RFC 9110's `HTTP-date`, `Range`, `ETag` quoting, the two tag-comparison functions and §13.2.2's conditionals — the one copy, shared by the two HTTP transports; `mountx/s3` re-exports them under its own names |
| `auto.ts` | `mountx/auto` — probe, then FUSE → 9P → NFS, each behind `await import()` |

### Drivers (`src/drivers/`)

Expand Down Expand Up @@ -122,6 +123,28 @@ is no RFC; everything is transcribed from Amazon's docs and named where it is us
| `session.ts` | one request in, one reply out, streaming **both ways**. Derived ETags, multipart staged under a reserved prefix |
| `server.ts` | loopback-only without credentials; ordered drain on `close()` |

## WebDAV (`src/webdav/`, exported as `mountx/webdav`)

The other transport that is not a mount, and the one a kernel can mount anyway
without root or native code (`davfs2`, `mount_webdav`, the Windows redirector).
**RFC 4918 classes 1, 2 and 3** — every method the specification defines, write
locks included — transcribed from the RFC, with RFC 9110 for the HTTP it rides on
and RFC 4331 for the quota pair.

| File | What |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `constants.ts` | the errno → HTTP status table, typed **total** over `ErrnoCode` (the same shape as `s3/constants.ts`'s), the protocol's literals, and the `propstat` phrases |
| `protocol.ts` | pure: target ↔ `href` (decoded and encoded **per segment**), `Depth`/`Overwrite`/`Destination`/`Timeout`/`Lock-Token`/`If`, the three request grammars, and every document |
| `locks.ts` | the write-lock table (§6, §7): pure, synchronous, **clockless** — `now` is an argument. Scope is a prefix test; a lock never follows its resource |
| `session.ts` | method semantics over one driver. No handle table and no `PathLock` — HTTP carries no per-connection state, so a request resolves its own paths and is done |
| `server.ts` | the socket, and the only file here that imports `node:http`. Loopback-only without credentials; HTTP Basic with them |

The deliberate gaps, each recorded at its own definition: no dead properties
(`PROPPATCH` writes `getlastmodified` through `utimes` and answers `403
cannot-modify-protected-property` for the rest — a driver has nowhere to keep a dead
one), RFC 9110's conditionals on `GET`/`HEAD`/`PUT` only, and no lock-null
resources (§7.3's _locked empty resource_ instead, which is a real file).

## CLI (`src/cli/`, the `mountx` bin, `pnpm mountx` from source)

A demo and a test bench, not a mount tool: it mounts this package's own `README.md`
Expand Down Expand Up @@ -197,10 +220,28 @@ The facts no single file's header can own.
after a failure, and no probe when a transport is named. `p9ModuleRefusal` lives
there rather than in `9p/probe.ts` because it is a judgement call the no-fallback
rule forces, not a fact about the host.
- **`mountx/s3` is outside `auto` on purpose** — `auto`'s contract is a mountpoint,
and the gateway never produces one.
- **`mountx/s3` and `mountx/webdav` are outside `auto` on purpose** — `auto`'s
contract is a mountpoint, and neither serving transport produces one. WebDAV is
the one whose _client_ can produce one (`davfs2`, `mount_webdav`), which is a fact
about the host's tooling rather than something this package does.
- **`src/webdav/protocol.ts` imports `src/s3/xml.ts`.** The second deliberate
cross-transport dependency, after 9P→`fuse/flags.ts`, and the same argument: a
bounded XML encoder and a copying, DOCTYPE-refusing parser are facts about XML, not
about S3, and a second hardened parser is a second thing to get wrong. What it
costs is namespaces — the parser reports local names — which `webdav/protocol.ts`
documents in full at its header. `xml.ts` pulls in only `s3/constants.ts`, so
`mountx/webdav` does not load a signature or a chunked decoder.
- **`src/http.ts` is the HTTP the two HTTP transports share** — `formatHttpDate` /
`parseHttpDate`, `parseRange`, the `Content-Range`/`ETag` spellings, the strong
and weak entity-tag comparisons, and `evaluateConditionals`, all RFC 9110. It was
`src/s3/protocol.ts`'s until WebDAV needed the same rules; `s3/protocol.ts`
re-exports every symbol under its old name, so `mountx/s3`'s surface never moved.
`evaluateConditionals` is the one wrapped rather than re-exported: SigV4 signs
headers as they were sent, so that transport keeps a list and joins its repeated
`If-Match`/`If-None-Match` lines (RFC 9110 §5.3) into the record the shared rule
reads, while WebDAV hands over the one `node:http` already gave it.
- **Platforms.** FUSE is Linux; 9P is Linux and root-only; NFS is Linux (root) and
macOS (no root, behind a consent gate); S3 is anywhere. macOS gets NFS by necessity
macOS (no root, behind a consent gate); S3 and WebDAV are anywhere. macOS gets NFS by necessity
— macFUSE is a third-party kext with its own dialect, so `src/fuse/` cannot serve
it.
- **`memory.ts` is the only driver with `mountx.mknod`**, which is what keeps the
Expand Down
119 changes: 119 additions & 0 deletions .agents/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -408,6 +419,114 @@ until something asks:
and falls back to **size plus modification time**. `--size-only` is the
comparison with no hash in it at all.

## davfs2, the WebDAV mount client (installed 2026-07-31, this Linux host)

`test/webdav/mount.test.ts` is the one Tier-2 column that needs it. **`pnpm
test:webdav:mount` passes**: 5 passed, 2 skipped, ~0.2 s of tests.

- **`/usr/sbin/mount.davfs`, davfs2 1.7.3**, from `sudo dnf install davfs2`
(pulls `neon` 0.37.1 and `libntlm`; creates the `davfs2` user and group). It
needs **no kernel module of its own**: davfs2 mounts through **FUSE**, which
this host already has, and the mount table line names the share as its source:

```
http://127.0.0.1:37487/ /tmp/…/mnt fuse rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other,max_read=65536 0 0
```

- **It needs root, and that is davfs2's rule rather than the kernel's.**
`mount.davfs` is `-rwsr-xr-x root root`, but an unprivileged caller is refused
with `no entry for <path> found in /etc/fstab` — the unprivileged route wants
an `/etc/fstab` line plus `davfs2` group membership, neither of which a test
may arrange. So this column is sudo-only, like 9P. Nothing about the _server_
needs privilege: it is an ordinary user's process on an ordinary TCP socket.
- **The configuration goes in a file passed with `-o conf=`**, so
`/etc/davfs2/davfs2.conf` is neither read nor written — the same
"configuration entirely outside the developer's own" trick
`test/webdav/oracle.test.ts` plays with `RCLONE_CONFIG=""`. The mount line the
suite uses, verbatim:

```sh
mount.davfs http://127.0.0.1:PORT/ /mnt/point -o conf=…/davfs2.conf,rw,uid=0,gid=0
```

- **davfs2 writes either way, and locks when it is offered locks.** Both halves
were measured here by shadowing `session.handleRequest` and counting methods,
and they are worth keeping apart:
- Against **class 1** (`DAV: 1, 3`, which this server sent before locking
landed) it prints `mount.davfs: warning: the server does not support locks`
and mounts read-write anyway — **not one `LOCK` was sent**. So the class-2
gap cost this client nothing; macOS's `mount_webdav` is the client that
insists, and it is a different client.
- Against **class 2** (`DAV: 1, 2, 3`) it takes a lock per write and releases
it: `echo > f && cp f g` over the mount is `OPTIONS 1, PROPFIND 2, HEAD 2,
LOCK 2, PUT 2, UNLOCK 2`. The warning is gone. That is what makes
`test/webdav/mount.test.ts` a check on the _locking_ path rather than only
on the class-1 one, and `use_locks` is left at its default of on for exactly
that reason.
- **HTTP Basic works through the mount.** `-o username=ada` with the password on
the helper's stdin mounts; a wrong password fails the mount outright with
`Could not authenticate to server: rejected Basic challenge`, which is the
server's `401` being read rather than the client guessing.
- **`umount` hangs in this container, and `umount -i` does not.** Plain `umount`
runs `/sbin/umount.davfs`, which unmounts and _then_ polls until the
`mount.davfs` daemon leaves the process table. The daemon exits immediately,
but this container's pid 1 is not an init and never reaps it, so it sits there
`Z` forever and the helper waits forever with it. `umount -i` skips the helper,
issues the same `umount(2)`, and returns in ~9 ms with the table clear. This is
a container artefact, not a davfs2 defect — but the suite uses `-i`
unconditionally, because the helper's wait buys nothing it needs.
- **No wedge risk, unlike FUSE.** With the server killed under a live mount,
metadata is still answered from cache, a read that needs the network fails
immediately with `EAGAIN` (`Resource temporarily unavailable`) rather than
parking, and a plain `umount -i` still returns 0. Nothing here needs
`umount -f`, `fusectl` or an abort.
- **The one leak `umount -i` does leave is the cache.** davfs2 keeps a per-mount
cache at `/var/cache/davfs2/<host>+<mountpoint with the slashes turned into
dashes>+<owner>`, 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
Expand Down
Loading
Loading