Client Libraries

ZeroFS ships official client libraries for Python, TypeScript/Node, and Go. They are a programmatic alternative to mounting: your process speaks 9P2000.L straight to the server and gets path-based filesystem calls (read, write, stat, readdir, open) with no kernel mount in between.

All three wrap one Rust core (zerofs-client) compiled to a native library, so they share its behavior exactly: the same connection targets, the same fast-path extensions, the same reconnect and cancellation semantics. They connect to the same [servers.ninep] endpoint a 9P mount uses, with the same trusted-network model: a client asserts its uid at connect time and the server trusts it.

Installation

Python

pip install zerofs-client

The wheel bundles the native library (manylinux and musllinux on Linux, plus macOS), so the install is self-contained (no Rust toolchain, no build step). Import zerofs_client for the idiomatic facade; the low-level uniffi module is also importable as zerofs_ffi.

import zerofs_client

async with await zerofs_client.Client.connect("unix:/run/zerofs/9p.sock") as fs:
    await fs.create_dir_all("/demo", 0o755)
    await fs.write("/demo/notes.txt", b"hello from zerofs")

    data = await fs.read("/demo/notes.txt")
    meta = await fs.metadata("/demo/notes.txt")
    print(len(data), meta.size, meta.is_file())

    # A File handle for positioned I/O.
    async with await fs.open("/demo/data.bin", zerofs_client.OpenOptions(write=True, create=True)) as f:
        await f.write_at(0, b"\x00" * 4096)
        await f.sync_all()

    # Stream the directory entry by entry.
    async with await fs.open_dir("/demo") as d:
        async for entry in d:
            print(entry.name)

    await fs.rename("/demo/notes.txt", "/demo/notes.md")
    await fs.remove_file("/demo/notes.md")
    await fs.remove_dir_all("/demo")

TypeScript / Node

npm install zerofs-client

The matching @zerofs/ffi-<platform> native package installs as an optional dependency and is selected at load time. The bindings call the library through koffi rather than WASM, so sockets work.

import { Client, openOptions, isFile } from "zerofs-client";

await using fs = await Client.connect("unix:/run/zerofs/9p.sock");

await fs.createDirAll("/demo", 0o755);
await fs.write("/demo/notes.txt", new TextEncoder().encode("hello from zerofs"));

const data = await fs.read("/demo/notes.txt");
const meta = await fs.metadata("/demo/notes.txt");
console.log(data.length, meta.size, isFile(meta));

// A File handle for positioned I/O.
await using f = await fs.open("/demo/data.bin", openOptions({ write: true, create: true }));
await f.writeAt(0n, new Uint8Array(4096));
await f.syncAll();

// Stream the directory entry by entry.
await using dir = await fs.openDir("/demo");
for await (const entry of dir) console.log(entry.name);

await fs.rename("/demo/notes.txt", "/demo/notes.md");
await fs.removeFile("/demo/notes.md");
await fs.removeDirAll("/demo");

Go

go get github.com/Barre/ZeroFS/zerofs/zerofs-ffi/bindings/go

The module ships the generated cgo sources and a C header, but not the native library: Go has no package registry that carries binaries. You build the zerofs-ffi cdylib from the ZeroFS source and point cgo at it; the module README covers the CGO_CFLAGS/CGO_LDFLAGS setup. This path needs a Rust toolchain and a C toolchain.

import (
	"log"

	zerofs "github.com/Barre/ZeroFS/zerofs/zerofs-ffi/bindings/go"
)

fs, err := zerofs.ClientConnect("unix:/run/zerofs/9p.sock")
if err != nil {
	log.Fatal(err)
}
defer fs.Close()

must := func(err error) { if err != nil { log.Fatal(err) } }

must(fs.CreateDirAll("/demo", 0o755))
must(fs.Write("/demo/notes.txt", []byte("hello from zerofs")))

data, err := fs.Read("/demo/notes.txt")
must(err)
meta, err := fs.Metadata("/demo/notes.txt")
must(err)
log.Printf("%d bytes, size=%d, isFile=%v", len(data), meta.Size, meta.IsFile())

// Stream the directory with a range-over-func iterator.
dir, err := fs.OpenDir("/demo")
must(err)
defer dir.Close()
for entry, err := range dir.Entries() {
	must(err)
	log.Println(entry.Name)
}

Connecting

Client.connect(target) accepts these individual target forms:

TargetTransport
unix:/path/to.sockUnix socket
tcp://host:portTCP
host:port / hostTCP (port defaults to 5564)
/path or ./pathUnix socket (bare path)

For high availability, pass both 9P addresses as one comma-separated target string. The client probes the set concurrently and re-probes it on every reconnect:

LanguageHA connection
Pythonawait zerofs_client.Client.connect("node-a:5564,node-b:5564")
TypeScriptawait Client.connect("node-a:5564,node-b:5564")
Gozerofs.ClientConnect("node-a:5564,node-b:5564")

For an explicit identity (uid/gid/uname), an attach subtree (aname), a custom msize, or a connect timeout, connect with a ConnectOptions (connect_with in Python/Go, connectWith in Node). The server clamps msize to 10 MiB, so requesting more has no effect (the same ceiling a 9P mount hits).

API reference

The full surface is the zerofs-client core projected mechanically by uniffi, plus a thin per-language facade. The tables below are the canonical, language-neutral reference for every operation and type; the projection rules turn each into a concrete per-language signature.

Projection rules

Every operation maps to each language by the same rules, so a single canonical signature is unambiguous in all three.

ConceptPythonTypeScriptGo
Method namesnake_casesnake_case, camelCase aliasPascalCase
Async callawait (plus a blocking connect_sync())returns a Promiseblocking; returns (value, error)
Byte payloadbytesUint8Array[]byte
Path argumentstr or os.PathLikestringstring
Child name (*_at)bytesUint8Array[]byte
Optional valueNoneundefined*T (nil)
TimestampdatetimeDatetime.Time
Options recordOpenOptions(write=True)openOptions({ write: true })OpenOptions{Write: true}
Errorraises ZeroFsError.NotFoundthrows ZeroFsErrorNotFounderrors.Is(err, ErrZeroFsErrorNotFound)
Handle cleanupasync with / await close()await using / await close()defer Close()

Client operations

One identity, one session, safe to share across tasks. Path arguments are strings; returns are shown in canonical form (-> Type; no arrow means the call returns nothing).

OperationSignatureNotes
connect(target) -> ClientConstructor. Accepts one target or a comma-separated HA set.
connect_with(target, ConnectOptions) -> ClientConstructor with identity and tuning; accepts the same target set.
capabilities() -> CapabilitiesNegotiated session properties. Synchronous.
read(path) -> bytesWhole file into memory.
read_range(path, offset, len) -> bytesUp to len bytes at offset; a short result is EOF.
write(path, data)Create or truncate, then write all of data.
append(path, data) -> offsetAppend at end of file; returns where it landed.
stat(path) -> MetadataDoes not follow symlinks.
metadata(path) -> MetadataFollows symlinks (40-hop cap).
canonicalize(path) -> bytesResolve every symlink; canonical path bytes.
exists(path) -> boolAny file type, no symlink follow.
set_attr(path, SetAttrs) -> MetadataAny combination of metadata changes.
chmod(path, mode) -> MetadataPermission bits.
chown(path, uid?, gid?) -> MetadataA null field is left unchanged.
truncate(path, size) -> MetadataTruncate or extend.
set_times(path, atime?, mtime?) -> MetadataEach is a SetTime or null.
statfs() -> StatFsFilesystem usage and limits.
sync()Flush to durable storage (filesystem-global on ZeroFS).
create_dir(path, mode) -> MetadataParent must exist.
create_dir_all(path, mode)Create missing ancestors too.
remove_file(path)File, symlink, or device node.
remove_dir(path)Must be empty.
remove_dir_all(path)Recursive; not atomic.
rename(from, to)Atomic; replaces an existing target.
hard_link(original, link) -> MetadataNew name for an existing inode.
symlink(target, link_path) -> Metadatatarget is stored verbatim.
read_link(path) -> bytesRaw symlink target (lossless).
mknod(path, NodeKind, mode) -> MetadataFifo, socket, or device node.
read_dir(path) -> [DirEntry]Whole directory; . and .. excluded.
open_dir(path) -> DirCursor listing plus child operations.
open(path, OpenOptions) -> FilePositioned I/O.
create(path) -> FileRead-write, create, truncate, mode 0o644.
close()Mark closed and release the session.

File operations

An open file; all I/O is positioned (there is no implicit cursor), usable from many tasks at once.

OperationSignatureNotes
read_at(offset, len) -> bytesA short result is EOF.
write_at(offset, data)Any size; chunked internally.
metadata() -> Metadatafstat of this handle.
set_len(size)Truncate or extend.
set_attr(SetAttrs) -> MetadataMetadata changes through the handle.
sync_all()Data and metadata to durable storage.
sync_data()Data only.
close()Idempotent; never hangs.

Directory operations

A pull-based listing cursor plus openat-style child operations. Child names cross as bytes, not strings, so non-UTF-8 entries need no separate API: feed DirEntry.name_bytes back in verbatim.

OperationSignatureNotes
next_batch(max?) -> [DirEntry]Directory order; an empty list means end. A null max returns one server batch.
rewind()Restart from the first entry.
metadata() -> MetadataOf the directory itself.
set_attr(SetAttrs) -> MetadataOf the directory itself.
open_at(name, OpenOptions) -> Fileopenat(2).
open_dir_at(name) -> DirDescend without decoding the name.
metadata_at(name) -> Metadatafstatat(2); never follows symlinks.
set_attr_at(name, SetAttrs) -> MetadataChange a child without opening it.
create_dir_at(name, mode) -> Metadatamkdirat(2).
symlink_at(name, target) -> Metadatasymlinkat(2); target is raw bytes, verbatim.
link_at(original_dir, original_name, new_name) -> Metadatalinkat(2); original_dir is another open Dir.
mknod_at(name, NodeKind, mode) -> Metadatamknodat(2).
remove_file_at(name)unlinkat(2).
remove_dir_at(name)unlinkat(2) with AT_REMOVEDIR.
rename_at(old_name, new_dir, new_name)renameat(2); new_dir may be the same directory.
read_link_at(name) -> bytesreadlinkat(2).
close()Idempotent; never hangs.

Types

OpenOptions: read (false), write (false), create (false), create_new (false), truncate (false), mode (0o644). create_new is the atomic exclusive create: it fails unless this call creates the file. In TypeScript, build it with the openOptions(...) helper so the defaults apply.

ConnectOptions: uid (process euid), gid (process egid), uname ($USER, informational), aname ("", the default export), msize (1 MiB; the server clamps to 10 MiB), connect_timeout_ms (30000; null waits indefinitely).

SetAttrs: optional mode, uid, gid, size, atime, mtime. An all-empty SetAttrs is a no-op; atime/mtime are SetTime.

SetTime: Now (the server clock) or At { time } (an explicit timestamp).

NodeKind: Fifo, Socket, BlockDevice { major, minor }, or CharDevice { major, minor }.

FileType: File, Dir, Symlink, Fifo, Socket, CharDevice, BlockDevice, Unknown.

Metadata: ino, file_type, mode (full st_mode), nlink, uid, gid, size, block_size, blocks (512-byte units), rdev, and atime/mtime/ctime timestamps. btime and data_version are reserved (servers currently report 0).

DirEntry: name (UTF-8, lossy: invalid bytes become U+FFFD), name_bytes (exact on-wire bytes for the *_at methods), name_is_utf8, file_type, ino, and metadata (always returned inline by the private dialect's directory read).

StatFs: block_size, blocks, blocks_free, blocks_available, files, files_free, filesystem_id, max_name_len.

Capabilities: msize, max_read_chunk, max_write_chunk. This is a live snapshot that can change across reconnects; successfully connecting already guarantees the exact 9P2000.L.Z private dialect.

Idiomatic additions

uniffi generates a faithful but mechanical surface (async methods, raw handle objects). Each library layers a hand-written facade on top with the conveniences uniffi cannot express:

LibraryPackageAdds
Pythonzerofs-client (PyPI)async with on handles; async for entry in dir; streaming file.read_chunks() / file.write_from(); os.PathLike arguments; read_text/write_text; metadata predicates (meta.is_dir()); a blocking connect_sync() for non-async code; inline type hints (py.typed)
TypeScriptzerofs-client (npm)await using (Symbol.asyncDispose); for await (const e of dir); Web Streams (file.readable() / file.writable(), backpressure-aware); camelCase aliases beside the generated snake_case names
Gobindings/go moduledir.Entries() as a range-over-func iter.Seq2[DirEntry, error]; io.Reader/io.Writer over a File (so io.Copy streams); Call(ctx, fn)/Do(ctx, fn) context wrappers

Connection loss and cancellation

Reconnection. The session retries indefinitely with backoff. Undispatched calls block while no server is available. Session replay rebinds linked open handles by stable inode id and re-acquires recorded byte-range locks before publishing the connection.

An open handle with no remaining namespace link is connection-local. Disconnecting with such a handle makes the logical client terminal with Stale/ESTALE; recovery requires a new client and reopened paths. In-flight mutations retain their operation envelope across retries. Automatic retry ends at the 120-second protocol horizon, after which an unresolved result is reported as an ambiguous connection failure.

Cancellation. Host-language cancellation does not cancel Rust or server-side work. Caller waits can be bounded with asyncio.wait_for (Python), Promise.race (TypeScript), or the Call(ctx, fn)/Do(ctx, fn) context wrappers (Go). A timeout abandons the caller wait; the operation continues until it resolves or reaches the protocol horizon, and its result is discarded. The deadline bounds caller latency, not server execution.

Closing. close() marks a handle closed immediately, is idempotent, and never hangs. The server-side fid is released and its number recycled when the handle is dropped (or right after close() for scope-bound use), so a long-running client never exhausts fids.

Errors

Every fallible call surfaces one flat, exhaustive ZeroFsError. Exhaustive is deliberate: a new server-side variant fails the binding's build rather than silently degrading to a catch-all. The variant-to-errno mapping is 1:1, and error_to_errno(err) returns the Linux errno for any error (the Io variant carries its own).

VarianterrnoMeaning
NotFoundENOENTNo entry at the path.
PermissionDeniedEACCESDenied by permission bits.
NotPermittedEPERMRequires ownership or privilege.
AlreadyExistsEEXISTThe target already exists.
NotADirectoryENOTDIRA path component is not a directory.
IsADirectoryEISDIRA directory where a non-directory was required.
DirectoryNotEmptyENOTEMPTYRemoving a non-empty directory.
NameTooLongENAMETOOLONGA name exceeds 255 bytes.
InvalidArgumentEINVALBad input, client-side or from the server.
TooManySymlinksELOOPResolution exceeded the 40-hop cap.
ClosedEBADFA handle or client was used after close().
ConnectFailedEIOThe initial connection or attach failed.
StaleESTALEDurability lineage was lost, or reconnect could not replay the complete logical session.
Io(its own)Any other server errno, preserved verbatim.
ProtocolEIOWire-level failure: codec, unexpected reply, or negotiation.

Each language matches it idiomatically:

  • Python raises typed exceptions: except zerofs_client.ZeroFsError.NotFound.
  • TypeScript throws subclasses: err instanceof ZeroFsErrorNotFound.
  • Go exposes sentinels for errors.Is(err, zerofs_ffi.ErrZeroFsErrorNotFound).

Security


Next Steps

Was this page helpful?