NineP.Protocol 0.4.1

dotnet add package NineP.Protocol --version 0.4.1
                    
NuGet\Install-Package NineP.Protocol -Version 0.4.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="NineP.Protocol" Version="0.4.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="NineP.Protocol" Version="0.4.1" />
                    
Directory.Packages.props
<PackageReference Include="NineP.Protocol" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add NineP.Protocol --version 0.4.1
                    
#r "nuget: NineP.Protocol, 0.4.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package NineP.Protocol@0.4.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=NineP.Protocol&version=0.4.1
                    
Install as a Cake Addin
#tool nuget:?package=NineP.Protocol&version=0.4.1
                    
Install as a Cake Tool

ninep — 9P2000 / 9P2000.u / 9P2000.L for C# / .NET

ci NuGet NineP.Protocol NuGet NineP.Client NuGet NineP.Server .NET 8 | 10 License: MIT

9P is the file protocol of Plan 9: a client walks a tree of named files over a byte stream, and a server answers with whatever it chooses to present as files, from a real disk to a JSON document to a database. Linux mounts it as v9fs, QEMU and WSL share host directories over it, and it is the simplest sane way to give a program a filesystem-shaped API over a socket.

ninep is a complete, production-grade implementation for .NET of all three dialects in use today, 9P2000, 9P2000.u and 9P2000.L, each negotiated separately and none invented. You write a client in a dozen lines, or a server as a tree of small handler classes, and the library owns the protocol: negotiation, fids and tags, Tflush, walks, open state, directory packing, permissions, and every one of the wire-level rules a hostile peer would test.

Package What it holds
NineP.Protocol the typed messages of all 34 T/R pairs, a bounded zero-copy codec, the dialects and the unified Attr model, the TCP / TLS / WebSocket / in-memory transports, errors and the authentication interfaces
NineP.Client NinePClient, NinePSession, NinePFid: a pipelined client with a path-shaped file API and a one-method-per-message API underneath
NineP.Server NinePServer and the handler interfaces: you implement one class per file type and the server core does the rest

Targets net8.0 and net10.0. MIT licensed. Version 0.1.0; the history is in CHANGELOG.md. Two example servers, jsonfs and todofs, and a conformance ninep command-line tool live in the repository and are not published.

Install

dotnet add package NineP.Client
dotnet add package NineP.Server

NineP.Protocol comes with either. Add it on its own only for a peer that is neither, such as a proxy or a transport.

The packages log through Microsoft.Extensions.Logging.Abstractions (ILogger, at the 8.0 floor so your application chooses the version) and, on net8.0 only, use System.IO.Pipelines. That is the whole runtime dependency set.

A server in sixty seconds

A filesystem is a tree of handlers. Implement IFilesystem and one handler interface per file type; nothing below touches a message, a fid or a dialect.

static async Task<(NinePServer Server, Task Serving)> StartHelloServerAsync(NinePAddress address)
{
    var options = new ServerOptions { Listen = [address] };
    var server = new NinePServer(options);

    // ServeAsync runs until the server is stopped, so hold its task rather than awaiting it.
    Task serving = server.ServeAsync(new HelloFilesystem());
    await server.ListeningAsync();

    return (server, serving);
}

The tree behind it: one directory holding one read-only file.

sealed class HelloFilesystem : IFilesystem, IDirectoryHandler
{
    static readonly byte[] Greeting = "hello, 9P\n"u8.ToArray();
    static readonly HelloFile File = new(Greeting);

    public Qid Qid => new(QidType.QTDIR, 0, 1);

    public ValueTask<IDirectoryHandler> AttachAsync(
        Identity identity, string aname, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult<IDirectoryHandler>(this);

    public ValueTask<Attr> GetAttrAsync(CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(new Attr
        {
            Qid = Qid,
            Kind = FileKind.Directory,
            Perm = FilePermissions.OwnerAll                  // 0755
                 | FilePermissions.GroupReadExecute
                 | FilePermissions.OtherReadExecute,
        });

    public ValueTask<IHandler?> LookupAsync(string name, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult<IHandler?>(name == "hello.txt" ? File : null);

    public ValueTask<DirectoryListing> ReadDirAsync(
        ulong cursor, int max, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(cursor == 0
            ? new DirectoryListing([new DirEntry("hello.txt", File.Qid, FileKind.File, 1)], 1, true)
            : new DirectoryListing([], cursor, true));

    // Everything this tree cannot do is one line each, and the core turns the exception into the
    // right error shape for whichever dialect the session negotiated.
    public ValueTask SetAttrAsync(SetAttr update, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask<IHandler> CreateAsync(CreateRequest request, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask RemoveAsync(string name, FileKind kind, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask RenameAsync(
        string oldName, IDirectoryHandler newParent, string newName, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask ClunkAsync(bool wasOpen, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;

    public ValueTask FsyncAsync(bool dataOnly, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;
}

sealed class HelloFile(byte[] contents) : IFileHandler, IOpenFile
{
    public Qid Qid => new(QidType.QTFILE, 0, 2);

    public ValueTask<Attr> GetAttrAsync(CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(new Attr
        {
            Qid = Qid,
            Kind = FileKind.File,
            Perm = FilePermissions.AllRead,                  // 0444
            Size = (ulong)contents.Length,
        });

    public ValueTask<IOpenFile> OpenAsync(
        OpenMode mode, OpenFlags flags, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult<IOpenFile>(this);

    public ValueTask<int> ReadAsync(
        ulong offset, Memory<byte> buffer, CancellationToken cancellationToken = default)
    {
        int at = (int)Math.Min(offset, (ulong)contents.Length);
        int count = Math.Min(buffer.Length, contents.Length - at);

        contents.AsMemory(at, count).CopyTo(buffer);
        return ValueTask.FromResult(count);
    }

    public ValueTask<int> WriteAsync(
        ulong offset, ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask<ulong> GetSizeAsync(CancellationToken cancellationToken = default) =>
        ValueTask.FromResult((ulong)contents.Length);

    public ValueTask SetAttrAsync(SetAttr update, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask ClunkAsync(bool wasOpen, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;

    public ValueTask FsyncAsync(bool dataOnly, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;

    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

Every T-message maps onto exactly one handler method; docs/server.md has the table. Optional capabilities such as locks, extended attributes, links and statfs are separate interfaces, and a handler that lacks one is answered EOPNOTSUPP rather than crashing.

A client in sixty seconds

static async Task<string> ReadHelloAsync(NinePAddress address)
{
    var options = new ClientOptions { Uname = Environment.UserName };

    await using NinePSession session = await NinePClient.ConnectAsync(address, options);
    await using NinePFid root = await session.AttachAsync();

    foreach (DirEntry entry in await session.ReadDirAsync("/"))
    {
        Console.WriteLine(entry.Name);
    }

    return Encoding.UTF8.GetString(await session.ReadFileAsync("/hello.txt"));
}

ReadDirAsync, ReadFileAsync, WriteFileAsync, MkdirAsync, RemoveAsync, RenameAsync, GetAttrAsync, SymlinkAsync and ReadlinkAsync are the path-shaped API on NinePSession; NinePFid is the handle in between, and session.Messages is the one-method-per-T-message API underneath. Reads and writes are chunked at iounit with four requests in flight by default, which is where the throughput in docs/benchmarks.md comes from.

Both halves in one program

Paste this into a console application that references NineP.Client and NineP.Server, and it prints the two lines below. CI installs the packed packages into exactly such a program and diffs its output, and the test suite runs it too, so it cannot drift from what ships.

using System.Text;
using NineP.Client;
using NineP.Protocol;
using NineP.Protocol.Auth;
using NineP.Protocol.Transports;
using NineP.Server;

var options = new ServerOptions { Listen = [NinePAddress.Parse("tcp://127.0.0.1:0")] };
await using var server = new NinePServer(options);

Task serving = server.ServeAsync(new HelloFilesystem());
await server.ListeningAsync();

await using (NinePSession session = await NinePClient.ConnectAsync(
    server.Endpoints[0], new ClientOptions { Uname = Environment.UserName }))
{
    await using NinePFid root = await session.AttachAsync();

    foreach (DirEntry entry in await session.ReadDirAsync("/"))
    {
        Console.WriteLine(entry.Name);
    }

    Console.Write(Encoding.UTF8.GetString(await session.ReadFileAsync("/hello.txt")));
}

await server.StopAsync(TimeSpan.FromSeconds(5));
await serving;

sealed class HelloFilesystem : IFilesystem, IDirectoryHandler
{
    static readonly byte[] Greeting = "hello, 9P\n"u8.ToArray();
    static readonly HelloFile File = new(Greeting);

    public Qid Qid => new(QidType.QTDIR, 0, 1);

    public ValueTask<IDirectoryHandler> AttachAsync(
        Identity identity, string aname, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult<IDirectoryHandler>(this);

    public ValueTask<Attr> GetAttrAsync(CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(new Attr
        {
            Qid = Qid,
            Kind = FileKind.Directory,
            Perm = FilePermissions.OwnerAll                  // 0755
                 | FilePermissions.GroupReadExecute
                 | FilePermissions.OtherReadExecute,
        });

    public ValueTask<IHandler?> LookupAsync(string name, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult<IHandler?>(name == "hello.txt" ? File : null);

    public ValueTask<DirectoryListing> ReadDirAsync(
        ulong cursor, int max, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(cursor == 0
            ? new DirectoryListing([new DirEntry("hello.txt", File.Qid, FileKind.File, 1)], 1, true)
            : new DirectoryListing([], cursor, true));

    // Everything this tree cannot do is one line each, and the core turns the exception into the
    // right error shape for whichever dialect the session negotiated.
    public ValueTask SetAttrAsync(SetAttr update, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask<IHandler> CreateAsync(CreateRequest request, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask RemoveAsync(string name, FileKind kind, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask RenameAsync(
        string oldName, IDirectoryHandler newParent, string newName, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask ClunkAsync(bool wasOpen, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;

    public ValueTask FsyncAsync(bool dataOnly, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;
}

sealed class HelloFile(byte[] contents) : IFileHandler, IOpenFile
{
    public Qid Qid => new(QidType.QTFILE, 0, 2);

    public ValueTask<Attr> GetAttrAsync(CancellationToken cancellationToken = default) =>
        ValueTask.FromResult(new Attr
        {
            Qid = Qid,
            Kind = FileKind.File,
            Perm = FilePermissions.AllRead,                  // 0444
            Size = (ulong)contents.Length,
        });

    public ValueTask<IOpenFile> OpenAsync(
        OpenMode mode, OpenFlags flags, CancellationToken cancellationToken = default) =>
        ValueTask.FromResult<IOpenFile>(this);

    public ValueTask<int> ReadAsync(
        ulong offset, Memory<byte> buffer, CancellationToken cancellationToken = default)
    {
        int at = (int)Math.Min(offset, (ulong)contents.Length);
        int count = Math.Min(buffer.Length, contents.Length - at);

        contents.AsMemory(at, count).CopyTo(buffer);
        return ValueTask.FromResult(count);
    }

    public ValueTask<int> WriteAsync(
        ulong offset, ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask<ulong> GetSizeAsync(CancellationToken cancellationToken = default) =>
        ValueTask.FromResult((ulong)contents.Length);

    public ValueTask SetAttrAsync(SetAttr update, CancellationToken cancellationToken = default) =>
        throw new NinePException(NinePError.FromErrno(Errno.EROFS));

    public ValueTask ClunkAsync(bool wasOpen, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;

    public ValueTask FsyncAsync(bool dataOnly, CancellationToken cancellationToken = default) =>
        ValueTask.CompletedTask;

    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
hello.txt
hello, 9P

What is implemented

  • Every message of all three dialects: 34 T/R pairs, 66 records, and the 77 golden wire vectors round-tripping byte-exactly.
  • Transports: tcp://, tls:// (TLS 1.2 floor, 1.3 preferred, optional mutual TLS), ws:// and wss:// (RFC 6455, one 9P message per binary frame, origin allow-list), and memory:// in process. Implementing ITransport adds your own; see docs/transports.md.
  • Authentication: the Tauth afid exchange, with TokenAuthenticator and PasswordAuthenticator (PBKDF2-HMAC-SHA-256, 600 000 iterations) shipped, TlsClientCertAuthenticator for mutual TLS, and IAuthenticator for your own; the Keycloak bearer-token authenticator in todofs is the worked example. Plan 9's p9any/p9sk1 is out of scope: it is DES-based and not production security.
  • Resource caps on everything a client controls: msize, the pre-negotiation frame size, fids, in-flight requests per connection and per listener, connections per listener, header timeouts, and the size and duration of an afid exchange. See docs/security.md.
  • Honest failure: nothing silent maps to success. A request the negotiated dialect cannot carry is refused rather than sent with a field dropped, and every framing violation closes the connection with a logged reason.

Interop

Measured against other implementations, in both directions, on 2026-09-10; every row is an opt-in test in the suite that anyone can rerun (tests/interop/setup.sh fetches the peers). The detail, the commands and what the runs found are in docs/interop.md.

peer role dialects result
Linux kernel v9fs (6.1, Debian 12) client, mounts our server 9P2000, 9P2000.u, 9P2000.L pass
diod 1.0.24 server, driven by our client 9P2000.L pass
hugelgupf/p9 p9ufs v0.4.1 server, driven by our client 9P2000.L pass
plan9port 9p (2026-08-26) client, reads our server 9P2000 pass

Before you mount a tree

Linux mounts 9P natively. macOS and Windows cannot mount it at all — macOS has no client (/sbin/mount_9p mounts a Virtualization.framework share by tag from inside a guest and cannot address a TCP server), and Windows has no v9fs. On both, the route is a bridge: a Linux container mounts the tree over 9P and re-exports it over SMB. Since most .NET developers are on one of those two, this is the common case rather than an exotic one.

That path drives a server through a kernel client and a file-sharing server, and it asks for things a hand-written 9P client never asks for. Six of them present as a broken tool rather than as a missing feature — a server that does not answer Tstatfs fails the SMB mount with Operation not supported and nothing naming 9P; a write-only file at mode 0222 cannot be opened at all; trans=tcp given a host name fails Invalid argument; a share exported read only = yes strips write bits from everything. Read docs/mounting.md before you debug a mount. It has the bridge recipe, the mount options that are not optional, the SMB caveats, and a checklist.

Before you expose a server

A server with no ServerOptions.Authenticator accepts a Tattach with afid = NOFID and runs the session as the identity the client claimed. That is right behind an already-authenticated transport, such as mutual TLS or a private network, and wrong anywhere else. A server an untrusted peer can reach must set an authenticator; one whose IsRequired is true refuses a NOFID attach with EACCES. When an authenticator is configured the session runs as the identity it produced, never the claimed uname, and the afid is bound to the (uname, n_uname, aname) it was created for. docs/auth.md has the full picture, including how a token is obtained.

Documentation

docs/api.md the complete public API, type by type and member by member
docs/protocol.md the wire protocol and how it maps onto the C# types
docs/server.md the handler table: every T-message to the handler method that serves it
docs/client.md sessions, fids, pipelining, cancellation and the file API
docs/transports.md the addresses, the four shipped transports, and writing your own
docs/auth.md the afid exchange, the shipped authenticators, and OIDC
docs/examples.md jsonfs, todofs and the ninep cli
docs/security.md the caps, the validation rules, and what is out of scope
docs/benchmarks.md measured throughput, latency, peak RSS and codec ns/op
docs/interop.md what this implementation has been run against, and what it has not
docs/mounting.md mounting a tree — natively on Linux, through a bridge on macOS and Windows
docs/releasing.md how a version reaches nuget.org: tag, checks, Trusted Publishing
docs/api/ the generated API reference (dotnet tool restore && dotnet docfx metadata && dotnet docfx build)

Building and testing

dotnet build -warnaserror && dotnet test && dotnet format --verify-no-changes
dotnet run --project tests/NineP.Conformance -- self
dotnet pack -c Release -o artifacts

Warnings are build failures and every suppression carries a reason. The conformance run drives the repository's own jsonfs and ninep as processes, in all three dialects over TCP, TLS, WebSocket and the in-memory transport, and diffs the result against a fixed expected output. CI runs the same gate on Linux, macOS and Windows.

Contributing

main is protected: nobody pushes to it, and only the owner merges. Fork the repository, branch from main, keep the gate above green, and open a pull request. CONTRIBUTING.md has the details, including the dependency and documentation rules a change must satisfy.

Licence

MIT. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on NineP.Protocol:

Package Downloads
NineP.Server

A 9P server whose core owns the protocol and whose filesystems are written as per-file-type handlers.

NineP.Client

A pipelined 9P client for 9P2000, 9P2000.u and 9P2000.L with exact flush semantics and a high-level file API.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.4.1 344 9/16/2026
0.4.0 249 9/11/2026
0.3.0 118 9/11/2026
0.2.0 120 9/10/2026
0.1.0 156 9/8/2026