From 28b5555e71a76af70af7905013699e3915362ac2 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 31 Jul 2026 22:22:19 +0000 Subject: [PATCH] test(9p): run the conformance special-files cases over the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `p9Driver` gains `mountx.mknod` — a `Tmknod`, with `dev` split the way `P9Session` puts it back together — so the five special-files cases run over the 9P column instead of skipping: `stat` and `readdir` agreeing on the type predicates off `Rgetattr`'s own encoding, `rdev` surviving the `major`/`minor` round trip, a FIFO renamed and unlinked as an ordinary name, and `EEXIST`/`ENOENT`/`EPERM` arriving as `Rlerror`. `THROUGH_9P` declares the extension for what it carries; the node-fs oracle overrides it back to `[]`, because that target implements no `mountx.mknod` and `#mknod` answers `ENOSYS` there — which is the difference between a capability and a claim. The other columns keep the skip for reasons of their own, now named where the blanket "no wire representation" line used to be. Co-Authored-By: Claude Opus 5 --- .agents/conformance-matrix.md | 32 ++++++++++++++++---------------- .agents/testing.md | 13 ++++++++++--- test/9p/client.ts | 28 +++++++++++++++++++++++++++- test/9p/conformance.test.ts | 33 ++++++++++++++++++++++++++------- test/matrix.ts | 22 ++++++++++++++-------- 5 files changed, 93 insertions(+), 35 deletions(-) diff --git a/.agents/conformance-matrix.md b/.agents/conformance-matrix.md index c39735a..c005e17 100644 --- a/.agents/conformance-matrix.md +++ b/.agents/conformance-matrix.md @@ -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. @@ -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 diff --git a/.agents/testing.md b/.agents/testing.md index ea4ab0e..d164e1e 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -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 diff --git a/test/9p/client.ts b/test/9p/client.ts index 4c7eab8..552476f 100644 --- a/test/9p/client.ts +++ b/test/9p/client.ts @@ -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))); }, diff --git a/test/9p/conformance.test.ts b/test/9p/conformance.test.ts index cd9ce16..9eca35b 100644 --- a/test/9p/conformance.test.ts +++ b/test/9p/conformance.test.ts @@ -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 @@ -71,7 +82,7 @@ const THROUGH_9P: ResolvedCapabilities = { caseSensitive: true, statfs: true, readOnly: false, - extensions: [], + extensions: ["mknod"], }; /** @@ -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. diff --git a/test/matrix.ts b/test/matrix.ts index 6a9b3cf..f9ee65d 100644 --- a/test/matrix.ts +++ b/test/matrix.ts @@ -444,11 +444,16 @@ function main(): void { "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).", + "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.", ); push(); push( @@ -470,9 +475,10 @@ function main(): void { const missing = [...(unmet.get(column.key) ?? [])]; // `root` is an environment fact, reported in its own cell. A `mountx.*` // requirement is neither a capability nor a loss: the suite reaches an - // extension by name through `fs.mountx`, which only the loopback column - // has — every other column is a client on the far side of a wire, and the - // wire has no such handle. A skip there says nothing about whether the + // extension by name through `fs.mountx`, and whether a column's client + // offers that name is a fact about the client. The loopback and 9P columns + // do; the others skip for reasons that are theirs rather than the + // session's (see the preamble above). A skip says nothing about whether the // transport carries the *operation* (all four sessions carry `mknod`), so // calling it a loss would be a claim the run cannot make in either // direction. The per-case rows below still show the skips.