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
32 changes: 16 additions & 16 deletions .agents/conformance-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ One conformance suite (`test/conformance.ts`), written against the driver interf

Generated 2026-07-31 with `pnpm matrix`.

| Column | What it runs | Result |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **loopback** | `test/drivers.test.ts` — the driver behind `createLoopback`, no transport | 251 passed, 29 skipped (4 targets: memory; node-fs; unstorage; node:fs/promises (raw)) |
| **FUSE** | `test/fuse/conformance-mount.test.ts` — a real kernel mount, `node:fs` as the client | 130 passed, 10 skipped (2 targets: memory driver, through a FUSE mount; node-fs driver, through a FUSE mount) |
| **9P** | `test/9p/conformance.test.ts` — 9P2000.L through the codecs, the JS client from `test/9p/client.ts` | 192 passed, 18 skipped (3 targets: memory driver, over 9P; memory driver with no handles, over 9P; node-fs oracle, over 9P) |
| **NFSv3** | `test/nfs/v3/conformance.test.ts` — NFSv3 over a TCP socket, the JS client from `test/nfs/v3/client.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFS; node-fs driver, over NFS) |
| **NFSv4.1** | `test/nfs/v4/conformance.test.ts` — NFSv4.1 over a TCP socket, the JS client from `test/nfs/v4/client.ts` and the driver over it in `test/nfs/v4/driver.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFSv4.1; node-fs driver, over NFSv4.1) |
| **S3** | `test/s3/conformance.test.ts` — an S3 gateway in process, the JS client from `test/s3/client.ts` | 48 passed, 22 skipped (1 target: memory driver, over S3) |
| Column | What it runs | Result |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **loopback** | `test/drivers.test.ts` — the driver behind `createLoopback`, no transport | 251 passed, 29 skipped (4 targets: memory; node-fs; unstorage; node:fs/promises (raw)) |
| **FUSE** | `test/fuse/conformance-mount.test.ts` — a real kernel mount, `node:fs` as the client | 130 passed, 10 skipped (2 targets: memory driver, through a FUSE mount; node-fs driver, through a FUSE mount) |
| **9P** | `test/9p/conformance.test.ts` — 9P2000.L through the codecs, the JS client from `test/9p/client.ts` | 202 passed, 8 skipped (3 targets: memory driver, over 9P; memory driver with no handles, over 9P; node-fs oracle, over 9P) |
| **NFSv3** | `test/nfs/v3/conformance.test.ts` — NFSv3 over a TCP socket, the JS client from `test/nfs/v3/client.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFS; node-fs driver, over NFS) |
| **NFSv4.1** | `test/nfs/v4/conformance.test.ts` — NFSv4.1 over a TCP socket, the JS client from `test/nfs/v4/client.ts` and the driver over it in `test/nfs/v4/driver.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFSv4.1; node-fs driver, over NFSv4.1) |
| **S3** | `test/s3/conformance.test.ts` — an S3 gateway in process, the JS client from `test/s3/client.ts` | 48 passed, 22 skipped (1 target: memory driver, over S3) |

## Capability loss

Derived from the run, not declared here: a requirement counts as unmet in a column when no case that names it passed there. `root` is an environment fact rather than a transport one — it gates the one case that hands a file away, which only root may do — so it is reported in its own column, and _recorded_ there rather than derived: a column can skip that case for want of `symlinks` long before privilege is reached, so the absence of a pass is not evidence about the run's uid. Every column is run with root when root is reachable, including the five that do not need it. A `mountx.*` requirement is left out of this table entirely: the suite calls an extension by name through `fs.mountx`, which only the loopback column has, so a skip everywhere else is a fact about how the case is written and not about what the transport carries (all four sessions do carry `mknod` — see the per-case rows below, and the FUSE column's own `mkfifo`/`mknod`/`bind` case over a real mount).
Derived from the run, not declared here: a requirement counts as unmet in a column when no case that names it passed there. `root` is an environment fact rather than a transport one — it gates the one case that hands a file away, which only root may do — so it is reported in its own column, and _recorded_ there rather than derived: a column can skip that case for want of `symlinks` long before privilege is reached, so the absence of a pass is not evidence about the run's uid. Every column is run with root when root is reachable, including the five that do not need it. A `mountx.*` requirement is left out of this table entirely: the suite calls an extension by name through `fs.mountx`, so a skip is a fact about whether that column's client offers the name, not about what the transport carries. Two columns do offer it — the loopback one directly, and the 9P one because `Tmknod` carries the whole `mode` and `p9Driver` can hand it over unchanged. The rest skip for reasons of their own: FUSE drives a real mount with `node:fs` as the client, and `node:fs` cannot `mknod(2)`; NFSv3 and NFSv4.1 carry the file type in `ftype3`/`nfs_ftype4` rather than in the mode, so a client there could not offer the whole extension without deciding part of it itself; S3 has no way to name a FIFO at all. All four sessions do carry `mknod` — see the per-case rows below, and the FUSE column's own `mkfifo`/`mknod`/`bind` case over a real mount.

**What this direction of derivation cannot check.** A column declares its own capabilities (`THROUGH_FUSE`, `THROUGH_9P`/`THROUGH_9P_REOPENED`, `THROUGH_NFS`, `THROUGH_NFS4`, `THROUGH_S3`), and declaring one `false` skips every case that needs it — which is exactly what a real loss looks like from here. So a capability the transport _does_ carry, wrongly declared lost, is reported as a loss with nothing to contradict it; the evidence only ever runs the other way, from a passing case to a capability that must be present. Every entry below is therefore a claim the transport's own test file makes and the run did not refute, and the comment at each declaration is where the reasoning for it lives.

Expand Down Expand Up @@ -131,13 +131,13 @@ Derived from the run, not declared here: a requirement counts as unmet in a colu

### special files

| Case | Needs | loopback | FUSE | 9P | NFSv3 | NFSv4.1 | S3 |
| -------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------ | ------------------ | ------------------ | ------------------ | ------------------ | ------------------ |
| creates a FIFO and a socket that stat and readdir both name | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| carries the device number of a character and a block device | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| creates a regular file from a mode naming one, or naming no type | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| is an ordinary name once it exists: rename, unlink, stat again | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| refuses an existing name, a missing directory and a type with its own call | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| Case | Needs | loopback | FUSE | 9P | NFSv3 | NFSv4.1 | S3 |
| -------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------ | ------------------ | ------------------ |
| creates a FIFO and a socket that stat and readdir both name | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| carries the device number of a character and a block device | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| creates a regular file from a mode naming one, or naming no type | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| is an ordinary name once it exists: rename, unlink, stat again | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
| refuses an existing name, a missing directory and a type with its own call | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |

### metadata

Expand Down
13 changes: 10 additions & 3 deletions .agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,16 @@ through its Tier-1 JS client, and FUSE contributes a real-mount column.
passed a case naming it, not every one — the drivers sharing a column need not have
the same capabilities now that `unstorage` runs beside `memory`. A `mountx.*`
requirement is dropped from the "capabilities lost" table (not from the per-case
rows): the suite reaches an extension by name through `fs.mountx`, which only the
loopback column has, so a skip in a transport column is about how the case is
written and not about what the wire carries — all four sessions carry `mknod`.
rows): the suite reaches an extension by name through `fs.mountx`, and whether a
column's client offers that name is a fact about the client. Two do — the loopback
column directly, and the 9P one because `Tmknod` carries the whole `mode` and
`p9Driver.mountx.mknod` hands it over unchanged, so the special-files cases run
there against the memory targets. The rest skip for their own reasons: FUSE's
client is `node:fs`, which cannot `mknod(2)` (its column covers special files by
its own case and by pjdfstest instead); NFSv3 and NFSv4.1 put the file type in
`ftype3`/`nfs_ftype4` rather than in the mode, so a client there cannot offer the
whole extension without deciding part of it itself; S3 cannot name a FIFO. All
four sessions carry `mknod` either way.

## Per area

Expand Down
28 changes: 27 additions & 1 deletion test/9p/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,9 +1103,35 @@ export function p9Driver(client: P9Client, root: number): FsDriver {

return {
// A fid is server-side open state that outlives the name it was opened
// from, and `Trenameat` is one server operation.
// from, and `Trenameat` is one server operation. `extensions` is inferred
// from the keys of `mountx` below, so it is not here.
capabilities: { handles: true, atomicRename: true },

mountx: {
/**
* `Tmknod`, which is the one place this adapter offers a `mountx.*`
* member by name.
*
* It is not the extension crossing the wire — it is the wire operation
* that already exists wearing the name the driver interface has for it.
* 9P2000.L carries the whole `mode`, type bits included, and
* `P9Session.#mknod` hands it to `mountx.mknod` unchanged, so every
* answer the case sees is the far side's: the memory driver's own
* `EEXIST`, `ENOENT` and `EPERM`-for-a-directory, arriving as `Rlerror`.
* Nothing is decided here.
*
* `dev` comes apart the way `P9Session` puts it back together — one
* 8-bit split across the project — which is what makes the round trip
* through `major`/`minor` and back out of `Rgetattr`'s single `rdev[8]`
* worth testing at all.
*/
async mknod(path, mode, dev) {
await withParent(path, "mknod", (fid, name) =>
client.mknod(fid, name, { mode, major: dev >>> 8, minor: dev & 0xff }),
);
},
},

async stat(path) {
return withFid(path, true, async (fid) => statsOf(await client.getattr(fid)));
},
Expand Down
33 changes: 26 additions & 7 deletions test/9p/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,22 @@ import { P9Client, p9Driver } from "./client.ts";
* closes it — so a file unlinked while open stays readable through the fid
* that holds it, with no silly-rename anywhere. This is the capability the
* plan's `["fuse", "9p", "nfs"]` preference order is *about*.
* - **`extensions: []`**, as in both other transports: the `mountx.*` namespace
* is a driver-to-session channel with no wire representation. `mountx.mknod`
* and `mountx.utimens` are reachable from `Tmknod` and `Tsetattr`, but a
* client cannot ask for them by name and the suite's extension cases are not
* about what a transport happens to route.
* - **`extensions: ["mknod"]`**, which the other transport columns do not
* claim. 9P2000.L has an operation for it and carries the whole `mode`, type
* bits included: `p9Driver`'s `mountx.mknod` is a `Tmknod`,
* `P9Session.#mknod` passes the mode to the driver's extension unchanged, and
* every refusal the cases assert — `EEXIST`, `ENOENT`, `EPERM` for a
* directory — is the far side's answer arriving as `Rlerror`. That is the
* capability *carried*, not declared over: a client offering a by-name handle
* onto a wire operation is what a driver adapter is. `utimens` stays off the
* list: `Tsetattr` carries the nanoseconds, but this client spends them
* through `utimes`/`lutimes` and never asks for the extension by name.
*
* What the extension cases buy over `session.test.ts`'s dispatch tests is the
* rest of the surface: `stat` and `readdir` agreeing on the seven type
* predicates off the wire's own encoding, `rdev` surviving the `major`/`minor`
* split and `Rgetattr`'s single `rdev[8]` rejoining it, and a FIFO behaving
* as an ordinary name under `rename` and `unlink` afterwards.
*
* Everything else — hardlinks, symlinks, permissions, times, truncate, atomic
* rename, `statfs` — crosses intact, and each of those is a message of its own
Expand All @@ -71,7 +82,7 @@ const THROUGH_9P: ResolvedCapabilities = {
caseSensitive: true,
statfs: true,
readOnly: false,
extensions: [],
extensions: ["mknod"],
};

/**
Expand Down Expand Up @@ -137,7 +148,15 @@ describe("over a 9P2000.L session", () => {

conformance({
name: "node-fs oracle, over 9P",
capabilities: THROUGH_9P,
/*
* `THROUGH_9P`, minus the one entry that is the *driver's* to answer rather
* than the transport's: the oracle implements no `mountx.mknod`, so
* `#mknod` answers `ENOSYS` for every type but a regular file, exactly as
* it should. The column carries the extension; this target has none to
* carry, and declaring one here is the difference between a capability and
* a claim.
*/
capabilities: { ...THROUGH_9P, extensions: [] },
/*
* The oracle forwards the host kernel's errors, so the *code* is pinned and
* the *number* is allowed to be either family's.
Expand Down
Loading
Loading