diff --git a/.agents/conformance-matrix.md b/.agents/conformance-matrix.md index 19b53e1..1dfe0e8 100644 --- a/.agents/conformance-matrix.md +++ b/.agents/conformance-matrix.md @@ -11,13 +11,13 @@ Generated 2026-08-01 with `pnpm matrix`. | **loopback** | `test/drivers.test.ts` — the driver behind `createLoopback`, no transport | 252 passed, 32 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, 12 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` | 204 passed, 9 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, 18 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, 18 skipped (2 targets: memory driver, over NFSv4.1; node-fs driver, over NFSv4.1) | +| **NFSv3** | `test/nfs/v3/conformance.test.ts` — NFSv3 over a TCP socket, the JS client from `test/nfs/v3/client.ts` | 128 passed, 14 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` | 128 passed, 14 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, 23 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`, 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. +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. Four columns do offer it — the loopback one directly, the 9P one because `Tmknod` carries the whole `mode` and `p9Driver` can hand it over unchanged, and both NFS ones because MKNOD and CREATE are operations their clients can call by name. The NFS pair offers it in part: `ftype3`/`nfs_ftype4` carries the file type and the mode carries only permission bits, so the two cases needing a mode to name a type are gated on `mknod.anyType` and skip there rather than let a client decide them itself. The remaining columns skip for reasons of their own: FUSE drives a real mount with `node:fs` as the client, and `node:fs` cannot `mknod(2)`; 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,14 +131,14 @@ 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 | 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` + `mknod.anyType` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod + mknod.anyType | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod + mknod.anyType | skip: mountx.mknod + mknod.anyType | skip: mountx.mknod + mknod.anyType | -| 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 and a missing directory | `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 a type with its own call | `mountx.mknod` + `mknod.anyType` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod + mknod.anyType | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod + mknod.anyType | skip: mountx.mknod + mknod.anyType | skip: mountx.mknod + mknod.anyType | +| 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) | pass (memory driver, over NFS), skip (node-fs driver, over NFS) | pass (memory driver, over NFSv4.1), skip (node-fs driver, over NFSv4.1) | 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) | pass (memory driver, over NFS), skip (node-fs driver, over NFS) | pass (memory driver, over NFSv4.1), skip (node-fs driver, over NFSv4.1) | skip: mountx.mknod | +| creates a regular file from a mode naming one, or naming no type | `mountx.mknod` + `mknod.anyType` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod + mknod.anyType | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mknod.anyType | skip: mknod.anyType | skip: mountx.mknod + mknod.anyType | +| 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) | pass (memory driver, over NFS), skip (node-fs driver, over NFS) | pass (memory driver, over NFSv4.1), skip (node-fs driver, over NFSv4.1) | skip: mountx.mknod | +| refuses an existing name and a missing directory | `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) | pass (memory driver, over NFS), skip (node-fs driver, over NFS) | pass (memory driver, over NFSv4.1), skip (node-fs driver, over NFSv4.1) | skip: mountx.mknod | +| refuses a type with its own call | `mountx.mknod` + `mknod.anyType` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod + mknod.anyType | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mknod.anyType | skip: mknod.anyType | skip: mountx.mknod + mknod.anyType | ### metadata diff --git a/.agents/testing.md b/.agents/testing.md index d164e1e..a9807ba 100644 --- a/.agents/testing.md +++ b/.agents/testing.md @@ -53,15 +53,24 @@ through its Tier-1 JS client, and FUSE contributes a real-mount column. 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`, 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 + column's client offers that name is a fact about the client. Four do — the loopback + column directly, the 9P one because `Tmknod` carries the whole `mode` and + `p9Driver.mountx.mknod` hands it over unchanged, and both NFS ones because MKNOD + (§3.3.11) and CREATE (§18.4) are operations their clients can call by name — so the + special-files cases run there against the memory targets. The two that do not: 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. + its own case and by pjdfstest instead), and S3 cannot name a FIFO at all. All four + sessions carry `mknod` either way. +- A column may carry _part_ of an extension, which `capabilities.extensions` cannot + express: `ConformanceTarget.carries` is that, and `Carried` has one member, + `mknod.anyType`. NFSv3 and NFSv4.1 put the file type in `ftype3`/`nfs_ftype4` and + leave the mode carrying permission bits only, so a mode naming a regular file, a + directory or no type at all is a question neither wire can ask — and answering it in + the client would be the client inventing an errno the driver should have produced + (invariant 5). The two cases needing it are gated on `mknod.anyType`, both NFS targets + declare `carries: []`, and the adapters throw a plain, deliberately non-errno-shaped + `Error` for those types so that un-gating a case fails loudly instead of passing for + the wrong reason. Unset means carried whole, so nothing else had to change. ## Per area diff --git a/test/matrix.ts b/test/matrix.ts index 81b4049..1b5d2c1 100644 --- a/test/matrix.ts +++ b/test/matrix.ts @@ -446,14 +446,16 @@ function main(): void { "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.", + "transport carries. Four columns do offer it — the loopback one directly, the 9P one " + + "because `Tmknod` carries the whole `mode` and `p9Driver` can hand it over unchanged, and " + + "both NFS ones because MKNOD and CREATE are operations their clients can call by name. " + + "The NFS pair offers it in part: `ftype3`/`nfs_ftype4` carries the file type and the mode " + + "carries only permission bits, so the two cases needing a mode to name a type are gated on " + + "`mknod.anyType` and skip there rather than let a client decide them itself. The remaining " + + "columns skip for reasons of their own: FUSE drives a real mount with `node:fs` as the " + + "client, and `node:fs` cannot `mknod(2)`; 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( @@ -476,8 +478,8 @@ function main(): void { // `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`, 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 + // offers that name is a fact about the client. The loopback, 9P and two NFS + // 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 diff --git a/test/nfs/v3/client.ts b/test/nfs/v3/client.ts index 9d99af5..a84d716 100644 --- a/test/nfs/v3/client.ts +++ b/test/nfs/v3/client.ts @@ -34,8 +34,12 @@ import { MOUNTPROC3_NULL, MOUNTPROC3_UMNT, MOUNTPROC3_UMNTALL, + NF3BLK, + NF3CHR, NF3DIR, + NF3FIFO, NF3LNK, + NF3SOCK, NFS3_COOKIEVERFSIZE, NFS3_OK, NFS_PROGRAM, @@ -137,7 +141,16 @@ import type { TimeLike, WriteResult, } from "../../../src/types.ts"; -import { S_IFDIR, S_IFLNK, S_IFMT, S_IFREG } from "../../../src/types.ts"; +import { + S_IFBLK, + S_IFCHR, + S_IFDIR, + S_IFIFO, + S_IFLNK, + S_IFMT, + S_IFREG, + S_IFSOCK, +} from "../../../src/types.ts"; /** An RPC that came back as anything other than an accepted success. */ export class RpcError extends Error { @@ -685,6 +698,48 @@ function timeOf(value: TimeLike): { seconds: number; nseconds: number } { return toTime(ms); } +/** + * The `ftype3` a `mknod` mode is asking for — and a plain `Error` when it is + * asking for something MKNOD has no way to say. + * + * `mknoddata3` switches on `ftype3` and MKNOD's four legal arms are BLK, CHR, + * SOCK and FIFO (§3.3.11); the mode's `S_IFMT` reaches the wire *only* here, + * because `sattr3.mode` is masked to `0o7777` on the way out and again by + * `NfsSession.#mknod` on the way in. So a type outside those four cannot be + * asked for over NFSv3 at all. + * + * What is thrown for one is deliberately **not** errno-shaped: no `code`, no + * `errno`, nothing `rejects()` in the conformance suite could match. Invariant + * 5 is the whole point — a client that answered `EPERM` for `S_IFDIR` here + * would be inventing the refusal that the driver on the far side is supposed to + * make, and the column would pass a case it never carried. The cases that need + * it are gated on `mknod.anyType`, which this column does not claim; anyone who + * un-gates one gets this, loudly, instead of a fabricated errno. + */ +function ftype3Of(mode: number): number { + switch (mode & S_IFMT) { + case S_IFBLK: { + return NF3BLK; + } + case S_IFCHR: { + return NF3CHR; + } + case S_IFSOCK: { + return NF3SOCK; + } + case S_IFIFO: { + return NF3FIFO; + } + default: { + throw new Error( + `NFSv3 MKNOD cannot ask for the type in mode 0o${mode.toString(8)}: ` + + "`mknoddata3` has an arm for a block device, a character device, a socket " + + "and a FIFO, and nothing else carries the type", + ); + } + } +} + /** How many symlinks a resolution may traverse before it is a loop. */ const MAX_SYMLINKS = 40; @@ -851,9 +906,46 @@ export function nfsDriver(client: NfsClient, root: Uint8Array): FsDriver { return { // NFSv3 is stateless, so an open file has no server-side existence and // cannot survive `unlink`. `rename` is a single server operation, so it is - // atomic in the sense the capability means. + // atomic in the sense the capability means. `extensions` is inferred from + // the keys of `mountx` below, so it is not here. capabilities: { handles: false, atomicRename: true }, + mountx: { + /** + * MKNOD, 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. + * What NFSv3 carries is a *type* and a *mode*, in separate fields: the + * type in `ftype3` (see {@link ftype3Of}), the permission bits in + * `sattr3.mode`, and `NfsSession.#mknod` puts the two back together for + * the driver. So the four device-ish types route faithfully and nothing + * else does, which is what `carries` in `./conformance.test.ts` says. + * + * `dev` comes apart the way `NfsSession.#mknod` puts it back together — + * one 8-bit split across the project — which is what makes the round trip + * through `specdata3`'s `major`/`minor` and back out of `fattr3.rdev` + * worth testing at all. Every refusal the cases assert is the far side's, + * arriving as an `nfsstat3`: nothing is decided here. + */ + async mknod(path, mode, dev) { + const type = ftype3Of(mode); + const { dir, name } = await parentOf(path, "mknod"); + check( + await client.mknod( + dir, + name, + type, + { mode: mode & 0o7777 }, + { major: dev >>> 8, minor: dev & 0xff }, + ), + "mknod", + path, + ); + }, + }, + async stat(path) { return statsOf((await walk(path, true)).attr); }, diff --git a/test/nfs/v3/conformance.test.ts b/test/nfs/v3/conformance.test.ts index e86a07e..c149078 100644 --- a/test/nfs/v3/conformance.test.ts +++ b/test/nfs/v3/conformance.test.ts @@ -20,12 +20,13 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe } from "vitest"; +import { describe, expect, it } from "vitest"; import { createMemoryDriver } from "../../../src/drivers/memory.ts"; import { createNodeFsDriver } from "../../../src/drivers/node-fs.ts"; import { createLoopback, type ResolvedCapabilities } from "../../../src/harness.ts"; import { createNfsServer } from "../../../src/nfs/server.ts"; import type { FsDriver } from "../../../src/types.ts"; +import { S_IFDIR } from "../../../src/types.ts"; import { conformance } from "../../conformance.ts"; import { check, NfsClient, nfsDriver } from "./client.ts"; @@ -43,9 +44,20 @@ import { check, NfsClient, nfsDriver } from "./client.ts"; * a client-side trick — the Linux kernel's client does it, our test client * does not, and the server is right either way. The conformance suite's * "keeps an open handle readable after unlink" case is therefore skipped. - * - **`extensions: []`**, for the same reason as the FUSE column: the - * `mountx.*` namespace is a driver-to-session channel with no wire - * representation. + * - **`extensions: ["mknod"]`, carried in part.** MKNOD is a procedure of its + * own (§3.3.11), `nfsDriver`'s `mountx.mknod` is that procedure, and the + * session hands what arrives to the driver's extension — so a FIFO, a socket + * and both kinds of device node cross intact, `rdev` included, and every + * refusal the cases assert (`EEXIST`, `ENOENT`) is the far side's answer + * arriving as an `nfsstat3`. What does *not* cross is the type in the + * **mode**: `mknoddata3` switches on `ftype3`, a four-member enum for this + * purpose, and `sattr3.mode` is masked to `0o7777` at both ends. A mode + * naming a regular file, a directory or no type at all is therefore a + * question this wire cannot ask — so the two cases that ask it stay skipped, + * declared as `carries: []` on the target below rather than papered over by a + * client inventing an errno. `utimens` stays off the list: SETATTR carries + * nanoseconds, but this client spends them through `utimes`/`lutimes` and + * never asks for the extension by name. * * Everything else — hardlinks, symlinks, permissions, times, truncate, atomic * rename, `statfs` — crosses intact. @@ -61,7 +73,7 @@ const THROUGH_NFS: ResolvedCapabilities = { caseSensitive: true, statfs: true, readOnly: false, - extensions: [], + extensions: ["mknod"], }; /** Stand a server up over `driver`, connect to it, and MOUNT its root. */ @@ -87,12 +99,23 @@ describe("over an NFSv3 server", () => { conformance({ name: "memory driver, over NFS", capabilities: THROUGH_NFS, + // The extension is here, but not the half of it that needs `mknod`'s mode + // to carry the type — see `THROUGH_NFS` above. + carries: [], setup: () => serve(createMemoryDriver()), }); conformance({ name: "node-fs driver, over NFS", - capabilities: THROUGH_NFS, + /* + * `THROUGH_NFS`, minus the one entry that is the *driver's* to answer + * rather than the transport's: `node-fs` implements no `mountx.mknod`, so + * `NfsSession.#mknod` answers `NFS3ERR_NOTSUPP` for every MKNOD, 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_NFS, extensions: [] }, // The driver forwards the host kernel's errors, and `NFS3ERR_*` carries the // ones this suite asks about straight through. errors: "host", @@ -108,4 +131,31 @@ describe("over an NFSv3 server", () => { }; }, }); + + /** + * The other half of `carries: []`: what happens when something asks anyway. + * + * The two skipped cases prove the column does not *claim* the mode-typed half + * of `mknod`. This proves the client does not quietly supply it either — the + * refusal is a bare `Error` with no `code` and no `errno`, so `rejects()` + * could never match it and anyone who un-gates a case gets a failure naming + * the wire's limit rather than a fabricated `EPERM` (invariant 5). + */ + it("refuses a type NFSv3 has no ftype3 for, and not with an errno", async () => { + const served = await serve(createMemoryDriver()); + try { + const error = await served.fs.mountx!.mknod!("/dir", S_IFDIR | 0o755, 0).then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(Error); + expect(error).not.toHaveProperty("code"); + expect(error).not.toHaveProperty("errno"); + expect((error as Error).message).toContain("MKNOD"); + // Nothing reached the wire: the name is still free. + await expect(served.fs.stat("/dir")).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await served.cleanup(); + } + }); }); diff --git a/test/nfs/v4/conformance.test.ts b/test/nfs/v4/conformance.test.ts index 8b96dfc..3fe330c 100644 --- a/test/nfs/v4/conformance.test.ts +++ b/test/nfs/v4/conformance.test.ts @@ -20,12 +20,13 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe } from "vitest"; +import { describe, expect, it } from "vitest"; import { createMemoryDriver } from "../../../src/drivers/memory.ts"; import { createNodeFsDriver } from "../../../src/drivers/node-fs.ts"; import { createLoopback, type ResolvedCapabilities } from "../../../src/harness.ts"; import { createNfsServer } from "../../../src/nfs/server.ts"; import type { FsDriver } from "../../../src/types.ts"; +import { S_IFDIR } from "../../../src/types.ts"; import { conformance } from "../../conformance.ts"; import { Nfs4Client } from "./client.ts"; import { nfs4Driver } from "./driver.ts"; @@ -51,9 +52,19 @@ import { nfs4Driver } from "./driver.ts"; * giving the table an identity that outlives the last link — which is a * change to `src/`, not to this column. Real clients paper over the same gap * with silly-rename, at both versions. - * - **`extensions: []`**, for the reason the other two transport columns give: - * the `mountx.*` namespace is a driver-to-session channel with no wire - * representation. + * - **`extensions: ["mknod"]`, carried in part**, as in the v3 column and for + * the same reason one version down. v4 has no MKNOD: a special file is a + * CREATE (§18.4), `nfs4Driver`'s `mountx.mknod` is that CREATE, and + * `Nfs4Session.#create` hands what arrives to the driver's extension — so a + * FIFO, a socket and both kinds of device node cross intact, `rawdev` + * included, and every refusal the cases assert (`EEXIST`, `ENOENT`) is the + * far side's answer arriving as an `nfsstat4`. What does *not* cross is the + * type in the **mode**: `createtype4` switches on `nfs_ftype4` and `fattr4`'s + * `mode` is permission bits. A mode naming a regular file is OPEN's business + * and `NFS4ERR_BADTYPE` here (§15.1.4.1); a mode naming a directory would be + * `mkdir` rather than the `EPERM` `mknod(2)` owes it. So the two cases that + * ask the mode to carry a type stay skipped, declared as `carries: []` on the + * target below rather than papered over by a client inventing an errno. * * Everything else — hardlinks, symlinks, permissions, times, truncate, atomic * rename, `statfs` — crosses intact. @@ -69,7 +80,7 @@ const THROUGH_NFS4: ResolvedCapabilities = { caseSensitive: true, statfs: true, readOnly: false, - extensions: [], + extensions: ["mknod"], }; /** Stand a server up over `driver`, connect to it, and establish a session. */ @@ -110,13 +121,25 @@ describe("over an NFSv4.1 server", () => { conformance({ name: "memory driver, over NFSv4.1", capabilities: THROUGH_NFS4, + // The extension is here, but not the half of it that needs `mknod`'s mode + // to carry the type — see `THROUGH_NFS4` above. + carries: [], errors, setup: () => serve(createMemoryDriver()), }); conformance({ name: "node-fs driver, over NFSv4.1", - capabilities: THROUGH_NFS4, + /* + * `THROUGH_NFS4`, minus the one entry that is the *driver's* to answer + * rather than the transport's: `node-fs` implements no `mountx.mknod`, so + * `Nfs4Session.#create` answers `NFS4ERR_BADTYPE` for every device-ish + * type — §15.1.4.1's "the type is not supported by the server" — 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_NFS4, extensions: [] }, errors, setup: async () => { const backing = await mkdtemp(join(tmpdir(), "mountx-nfs4-")); @@ -130,4 +153,33 @@ describe("over an NFSv4.1 server", () => { }; }, }); + + /** + * The other half of `carries: []`: what happens when something asks anyway. + * + * The two skipped cases prove the column does not *claim* the mode-typed half + * of `mknod`. This proves the client does not quietly supply it either — the + * refusal is a bare `Error` with no `code` and no `errno`, so `rejects()` + * could never match it and anyone who un-gates a case gets a failure naming + * the wire's limit rather than a fabricated `EPERM` (invariant 5). A + * directory is the sharper of the two here: `NF4DIR` *is* `mkdir` on this + * wire, so passing the mode through would have made one. + */ + it("refuses a type NFSv4.1 has no createtype4 for, and not with an errno", async () => { + const served = await serve(createMemoryDriver()); + try { + const error = await served.fs.mountx!.mknod!("/dir", S_IFDIR | 0o755, 0).then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).toBeInstanceOf(Error); + expect(error).not.toHaveProperty("code"); + expect(error).not.toHaveProperty("errno"); + expect((error as Error).message).toContain("CREATE"); + // Nothing reached the wire: the name is still free. + await expect(served.fs.stat("/dir")).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await served.cleanup(); + } + }); }); diff --git a/test/nfs/v4/driver.ts b/test/nfs/v4/driver.ts index b8453ca..1f5acdc 100644 --- a/test/nfs/v4/driver.ts +++ b/test/nfs/v4/driver.ts @@ -44,8 +44,12 @@ import { toTime4, } from "../../../src/nfs/v4/attr.ts"; import { + NF4BLK, + NF4CHR, NF4DIR, + NF4FIFO, NF4LNK, + NF4SOCK, NFS4_OK, NFS4ERR_NOENT, OP_GETATTR, @@ -66,7 +70,16 @@ import type { TimeLike, WriteResult, } from "../../../src/types.ts"; -import { S_IFDIR, S_IFLNK, S_IFMT, S_IFREG } from "../../../src/types.ts"; +import { + S_IFBLK, + S_IFCHR, + S_IFDIR, + S_IFIFO, + S_IFLNK, + S_IFMT, + S_IFREG, + S_IFSOCK, +} from "../../../src/types.ts"; import { ANONYMOUS_STATEID, type Nfs4Client, @@ -107,6 +120,54 @@ const BLOCK_SIZE = 4096; */ const UNLINK_DIR_CODE: ErrnoCode = process.platform === "linux" ? "EISDIR" : "EPERM"; +/** + * The `nfs_ftype4` a `mknod` mode is asking for — and a plain `Error` when it + * is asking for something CREATE has no way to say. + * + * v4 has no MKNOD: a special file is a CREATE, and `createtype4` switches on + * `nfs_ftype4` (§18.4). The mode's `S_IFMT` reaches the wire *only* here — + * `fattr4`'s `mode` is permission bits, and `Nfs4Session.#create` takes the + * type from `objtype` and nothing else — so the four device-ish types route + * faithfully and the rest do not route at all: + * + * - `NF4REG` is `NFS4ERR_BADTYPE` by §15.1.4.1, because a regular file is + * created with OPEN. Diverting there would test this adapter's routing rather + * than the session's own regular-file fallback. + * - `NF4DIR` *is* `mkdir` on this wire, so a mode naming a directory would + * quietly make one instead of earning the `EPERM` `mknod(2)` owes it. + * + * What is thrown is deliberately **not** errno-shaped: no `code`, no `errno`, + * nothing `rejects()` in the conformance suite could match. Invariant 5 is the + * whole point — deciding `EPERM` here would be this client inventing a refusal + * that belongs to the driver on the far side, and the column would pass a case + * it never carried. The cases that need it are gated on `mknod.anyType`, which + * this column does not claim; anyone who un-gates one gets this, loudly, + * instead of a fabricated errno. + */ +function ftype4Of(mode: number): number { + switch (mode & S_IFMT) { + case S_IFBLK: { + return NF4BLK; + } + case S_IFCHR: { + return NF4CHR; + } + case S_IFSOCK: { + return NF4SOCK; + } + case S_IFIFO: { + return NF4FIFO; + } + default: { + throw new Error( + `NFSv4.1 CREATE cannot ask for the type in mode 0o${mode.toString(8)}: ` + + "`createtype4` names a block device, a character device, a socket, a FIFO " + + "and a symlink, a regular file is OPEN's and a directory is mkdir's", + ); + } + } +} + /** A `fattr4`'s worth of values as the `StatsLike` a driver has to return. */ export function stats4Of(values: Fattr4Values): StatsLike { const mode = modeType4Of(values.type ?? 0) | ((values.mode ?? 0) & 0o7777); @@ -422,8 +483,44 @@ export function nfs4Driver(client: Nfs4Client, root: Uint8Array): FsDriver { // same thing where it declines to advertise `OPEN4_RESULT_PRESERVE_UNLINKED`. // So an open file does not survive `unlink` here either; the capability is // declared lost in `./conformance.test.ts`, with the reasoning. + // `extensions` is inferred from the keys of `mountx` below, so it is not + // here. capabilities: { handles: false, atomicRename: true }, + mountx: { + /** + * CREATE of a device, a socket or a FIFO — 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. + * The type travels in `createtype4` (see {@link ftype4Of}), the + * permission bits in `fattr4`'s `mode`, and `Nfs4Session.#create` puts + * the two back together for the driver. So the four device-ish types + * route faithfully and nothing else does, which is what `carries` in + * `./conformance.test.ts` says. + * + * `dev` comes apart the way `Nfs4Session.#create` puts it back together — + * one 8-bit split across the project — which is what makes the round trip + * through `specdata4`'s `major`/`minor` and back out of `rawdev` worth + * testing at all. Every refusal the cases assert is the far side's, + * arriving as an `nfsstat4`: nothing is decided here. + */ + async mknod(path, mode, dev) { + const type = ftype4Of(mode); + const { dir, name } = await parentOf(path, "mknod"); + await client + .mknod(dir, name, type, { + mode: mode & 0o7777, + major: dev >>> 8, + minor: dev & 0xff, + }) + .catch((error: unknown) => { + throw retarget(error, path); + }); + }, + }, + async stat(path) { return stats4Of((await walk(path, true)).values); },