Fizz design decisions and rationale
=======================================

This file records intentional design choices, rejected alternatives, and
known trade-offs. It is not a TODO and not a changelog. The purpose is to
let a new contributor (or a future session) understand why things are the
way they are.


Goals and non-goals
-------------------

Goal: the smallest possible TCP network filesystem that works well on a
trusted home LAN, including machines as old as an Amiga 1200.

Non-goals: authentication, encryption, multi-user permissions, general
POSIX extended attributes, high concurrency, WAN use.

The guiding principle is: if a real Amiga 1200 with 2 MB RAM can implement
it, it belongs. Everything else is out of scope unless it can be added
without burdening the Amiga side.


Protocol
--------

Binary, big-endian, request/response over a persistent TCP connection.
A sequence number in every frame enables pipelining; no client uses it yet
but the infrastructure is there (the FUSE client's reader thread was added
specifically for this).

Stateful (persistent connection) over stateless: file handles require
state. Stateless would force path re-sending on every read/write, which is
costly over a slow link.

Operations: STAT, READDIR, OPEN, CREATE, CLOSE, READ, WRITE, MKDIR,
UNLINK, RMDIR, RENAME, TRUNCATE, STATFS, CHMOD, SETMTIME, SETXATTR.
The first twelve are the minimum for a working read/write mount; the
remaining four were added for permission round-tripping, timestamp
preservation, and Amiga metadata fidelity.

No OP_READLINK: a deliberate omission, not an oversight. See symlinks
section below.


Capability exchange (protocol v4+)
----------------------------------

After the hello handshake the client sends a capability blob and the server
responds with its own. Wire format: caplen(2, big-endian) text(caplen bytes),
where text is newline-delimited key=value pairs. The client sends first; the
server is the authority and responds with next=start or next=disconnect.

Keys sent by the client:
  offer=   capability names this side provides (comma-separated).
  need=    capability names the peer must offer; server disconnects if unmet.
  use=     use these caps if offered, ignore if absent.
  version= the client's negotiated protocol version (integer). Required from
           v6 onwards; servers reject clients that omit it or send a version
           below the server's FIZZ_PROTO_VERSION.
  next=    always "start" from the client side.

Keys sent by the server:
  offer=   capability names this server provides.
  next=    "start" (accepted) or "disconnect" (rejected).
  message= human-readable rejection reason, present only with next=disconnect.
           New clients print it verbatim; old clients fall back to a generic
           message and ignore this key.

Defined capability names:

  unix           server is a Unix/POSIX system; mode bits from stat() are
                 sent faithfully (owner/group/other rwx + setuid/setgid/sticky).
  amiga          server is an AmigaOS system; permissions are synthesised from
                 fib_Protection; path matching is always case-insensitive.
  iso-8859-1     server stores filenames in ISO-8859-1 (Latin-1) encoding.
  case           server resolves paths case-sensitively (default on Unix).
  amiga-case     server resolves paths case-insensitively using deterministic
                 Latin-1 fold (latin1_lower() in protocol.h). Replaces the
                 old locale-dependent nocase capability.
  amiga-comment  STAT and READDIR responses append an xattr block carrying
                 the Amiga file comment; OP_SETXATTR with key "amiga.comment"
                 writes it. See Extended attributes section.
  amiga-flags    STAT and READDIR responses append an xattr block carrying
                 the Amiga protection bits (SPAD+H subset); OP_SETXATTR with
                 key "amiga.flags" writes them. See Extended attributes section.

Server offers:
  Unix server:  unix,case,amiga-case (base); additionally amiga-comment,
                amiga-flags when the root filesystem supports xattrs (detected
                at startup via probe_xattr()).
  Amiga server: amiga,iso-8859-1,amiga-case,amiga-comment,amiga-flags

Client defaults (applied when none of use/need are specified):
  FUSE client (fizz-mount): use=unix,case
  Amiga client:             use=iso-8859-1,amiga-case,amiga-comment,amiga-flags

The server validates the client's need= against its own offer= and sends
next=disconnect (with message=) if any required cap is absent. Unknown use=
caps are silently ignored on both sides.

nocase_resolve(): the Unix server's case-insensitive path resolution (used
when amiga-case is negotiated) opens each parent directory and scans for a
match via latin1_ncasecmp(), then reconstructs the path with the on-disk
name. For create and mkdir the final component is left verbatim
(resolve_last=0) since it does not exist yet.

Encoding safety: UTF-8 and ISO-8859-1 high bytes (0x80-0xFF) do not overlap
with any wire-forbidden byte (0x00-0x1F, '/', ':', '\'). Both encodings
pass through the protocol untouched. The Amiga will display garbled output
for multi-byte UTF-8 sequences, but no protocol corruption occurs.

"fizz query HOST[:PORT]" (Amiga) and "fizz-mount --query HOST[:PORT]" (FUSE)
connect, perform the hello and capability exchange, print the server's
protocol version and offer=, and exit without mounting. Useful for
diagnosing capability mismatches.

Backward compatibility: FIZZ_PROTO_MIN_VERSION=3. A v4/v5 client connecting
to a v3 server sees the v3 version in the hello and skips the cap exchange,
falling back to case-sensitive mode. From v6, servers require clients to
include version=6 (or higher) in the cap blob and reject older clients with a
descriptive message=. v6 clients still degrade correctly when connecting to
older servers (they send version=<negotiated> and expect the older wire
format for that version).


Extended attributes (protocol v5)
----------------------------------

General POSIX xattrs are out of scope. Two Amiga-specific attributes are
supported because they carry data that has no counterpart in the basic
protocol and cannot be derived from POSIX metadata:

  amiga.comment  The file's comment string (Amiga fib_Comment). Raw bytes,
                 max 79, no null terminator on the wire. Maps to
                 ACTION_SET_COMMENT / fib_Comment on the Amiga side.

  amiga.flags    Protection bits that have no Unix equivalent: SPAD+H (script,
                 pure, archived, delete-protected, hold). Encoded as one byte,
                 active-high: bit0=D, bit1=A, bit2=P, bit3=S, bit4=H.
                 The rwx bits are still handled by OP_CHMOD; this byte covers
                 only the Amiga-specific subset. Maps to ACTION_SET_PROTECT
                 combined with the rwx path.

Wire format for xattr data (xattr block):
  count(1) [klen(1) key(klen) vlen(1) val(vlen)]* count

STAT and READDIR responses append one xattr block after their fixed fields
when the client has negotiated the relevant capability. READDIR sends one
block per entry. Both sides can send zero-entry blocks (count=0) for entries
that have no xattr data.

OP_SETXATTR (opcode 16) writes one or more xattr entries for a path:
  xattr_block_len(2) xattr_block path(remaining) -> empty

Removing an attribute (zero-length value) removes the underlying storage.
Sending a zero-length "amiga.comment" removes the xattr; sending a zero
flags byte removes the "amiga.flags" xattr.

Unix server platform wrappers: Linux (lgetxattr/lsetxattr/lremovexattr,
"user." prefix added internally by the wrapper), macOS (getxattr/setxattr/
removexattr with XATTR_NOFOLLOW, same "user." prefix), FreeBSD
(extattr_get_link/extattr_set_link/extattr_delete_link with
EXTATTR_NAMESPACE_USER, bare name -- FreeBSD encodes the namespace as a
parameter, not as a name prefix). All wrappers accept bare names
("amiga.comment"); each platform adds its own namespace prefix or
parameter internally. Other platforms compile with silent no-ops.

Runtime probe: at startup fizz-serve calls probe_xattr(g_root), which
reads a nonexistent attribute. If errno is ENOTSUP or EOPNOTSUPP the
filesystem does not support xattrs; the server omits amiga-comment and
amiga-flags from its offer so clients never negotiate them. Any other
result (including ENODATA/ENOATTR, which means "supported but not set")
confirms xattr support.


Dynamic payload limits (protocol v6)
-------------------------------------

The OP_WRITE response is extended in v6 from written(4) to
written(4) max_payload(4). The server includes the current per-connection
payload ceiling in every write ACK; the client updates its max_write from
each ACK and chunks subsequent writes (and the initial write) to stay within
it. The limit may change in either direction across ACKs, allowing the server
to reduce it under memory pressure or raise it when more resources are
available.

OP_READ is also capped server-side by the same limit: if the client's maxsize
exceeds get_max_write(), the server silently reads and returns fewer bytes.
The client handles this naturally since partial reads already trigger
follow-up requests.

get_max_write(const conn_t *c): stub function in both servers. Replace the
body with heuristics (e.g. AvailMem(MEMF_ANY) on Amiga, queue depth,
per-connection counters) to make the limit truly dynamic. The return value
must not exceed FIZZ_MAX_WRITE; the OP_WRITE wire payload is data+12
(fh+offset header), so the dispatch loop ceiling is FIZZ_MAX_WRITE+12.

Clients initialise max_write to FIZZ_V6_INIT_MAX_WRITE when connecting to a
v6 server. This conservative start avoids allocating a large chunk before
receiving the first ACK from a potentially resource-constrained server.
Against older servers (below v6), clients use FIZZ_MAX_WRITE and read only a
4-byte ACK.

The v6 write ACK format change is a hard protocol break: v6 servers reject
pre-v6 clients at cap exchange rather than sending a format the client cannot
parse. This is documented as an intentional one-off exception to the general
old-client compatibility policy.


Filesystem size (OP_STATFS)
---------------------------

OP_STATFS (opcode 15, protocol v3) returns total, used, and available byte
counts for the share. The Linux server calls statvfs(g_root) and reports
f_blocks/f_bavail in terms of f_frsize; used = total - avail (bavail, not
bfree, to exclude space reserved for root). The Amiga server calls Info()
on the root lock and derives the same three values from InfoData.

The FUSE client maps the result to struct statvfs with f_frsize = 512; Linux
df shows correct size, used, and percentage.

The Amiga client fetches OP_STATFS live on every ACTION_DISK_INFO (no
caching). AmigaDOS InfoData fields are 32-bit, so values are scaled: both
total and used block counts are right-shifted together until total fits in
a signed 32-bit LONG, and id_BytesPerBlock is multiplied by 2 for each
shift so the reported size in bytes remains correct.
For filesystems <= 2 GB no scaling is needed. For filesystems > 2 GB the
percentage display in Workbench is correct; the raw size shown by the Info
command is inherently limited by the 32-bit Amiga representation.


Path validation
---------------

safe_path() on the server enforces: must start with '/', no empty
components, no '.', no '..', no ':', no '\', no control bytes (0x00-0x1f).
These rules are intentionally strict so that the same validator is safe on
AmigaOS (where ':' is a volume separator and '//' means parent), Windows
(where '\' is a separator and ':' introduces alternate data streams), and
POSIX systems.

The assert at the end of safe_path() checks that the constructed host path
begins with g_root as a string. This is a sanity check on the string
construction, not a symlink-escape defence. See symlinks below.

h_readdir applies the same character rules to each entry name before
emitting it. Any name that would fail safe_path() (colon, backslash,
control byte) is silently skipped. This prevents a class of broken
listings where a name appears in readdir but every subsequent operation
on it returns EACCES because the path cannot be encoded in a valid request.


Reconnect
---------

Both clients attempt to reconnect automatically after a server disconnect,
retrying every RECONNECT_INTERVAL (5 s) for up to RECONNECT_ATTEMPTS (12)
tries (~60 s cap). Operations block during this window rather than failing
immediately, resuming transparently on success.

No open-file-handle state is restored after reconnect. The server assigns
new file handle numbers per connection; any handle from the previous
connection is stale and will produce EBADF on the next use. The
application must close and reopen the file. All path-based operations
(stat, readdir, open, mkdir, unlink, rename) resume without intervention.

The FUSE client (fizz-mount) implements reconnect in the reader thread,
which owns the socket lifecycle. do_rpc() and do_rpc_nowait() wait on
g_reconnect_cv instead of returning EIO immediately when g_dead is set;
the reader thread signals the condvar on success or on permanent failure
(g_perm_dead).

The Amiga client is single-threaded; reconnect runs inline inside
conn_dead(), which is called on any socket I/O error. The reconnect loop
uses Delay() between attempts. The triggering DOS packet always fails
(conn_dead always returns -EIO for the current op); subsequent packets
proceed normally after a successful reconnect.


Server signal handling (fizz-serve)
------------------------------------

SIGINT (first):  stop accepting new connections; wait for all active client
                 threads to finish, then exit.
SIGHUP:          same as first SIGINT (graceful shutdown).
SIGTERM:         forced exit immediately regardless of active clients.
SIGINT (second): same as SIGTERM.

The graceful shutdown loop calls pthread_cond_timedwait with a 5 s timeout
so it polls for all clients to finish. A self-pipe (g_wakefd) allows the
forced-exit path to wake a sleeping condvar immediately: the signal handler
writes one byte to the write end of the pipe; a wakeup thread reads it and
signals the condvar. All signal handlers are async-signal-safe (write(2) to
a pipe is POSIX safe); no library calls are made from within a handler.

The forced path (SIGTERM / second SIGINT) exits without waiting. Active
client threads are abandoned; the OS cleans up sockets and memory.


Symlinks
--------

The current behaviour is intentionally "dirty but practical":

- lstat() is used for STAT and READDIR, so symlinks are reported as
  NT_LINK to clients.
- Regular (symlink-following) syscalls are used for OPEN, READDIR
  (opendir), MKDIR, RENAME, TRUNCATE, and UNLINK. A path that crosses a
  symlink will work on the server even though the symlink itself is
  reported as NT_LINK.
- There is no OP_READLINK. Clients cannot resolve symlink targets.
- NT_LINK entries should be skipped by clients that do not support them.

Two cleaner alternatives were considered:

  Fully transparent (stat everywhere, never emit NT_LINK): removes the
  inconsistency and needs no OP_READLINK. Rejected because directory
  symlinks can cause infinite traversal loops in recursive client
  operations (cp -r, backup tools). The server is stateless between
  requests and cannot detect these loops. No inode numbers are exposed
  in the protocol, so clients cannot detect them either.

  Drop all symlinks (hide NT_LINK from readdir/stat, return ENOENT):
  eliminates loop risk entirely and is clean for non-POSIX clients (Amiga
  has no POSIX symlinks). Rejected because it silently breaks things users
  have on disk, with no indication to them of why files are missing.

The current approach is a pragmatic middle ground: symlinks are visible
and usable if the client handles NT_LINK, and silently ignorable if it
does not. The known limitations (no OP_READLINK, loop risk, symlink
escape) are documented in protocol.h and in README Known Limitations.


Security model
--------------

No authentication. Any host that can reach the TCP port has full
read/write access. This is intentional and documented; the target use case
is a crossover cable, home switch, or VPN with trusted hosts only.

safe_path() blocks path traversal (.., //, etc.) but does not block
symlinks pointing outside the share root. This is also intentional: for a
trusted-LAN prototype, the complexity of per-component O_NOFOLLOW path
walking is not justified. The caveat is documented.

--readonly mode rejects all write ops at the server with EROFS. It does
not affect read access or the auth model.


Amiga server: concurrency model
--------------------------------

The Amiga server uses CreateNewProc (not CreateTask) so each connection
has its own full Process context and an isolated IoErr() / pr_Result2.

Three concurrency models were considered for the Amiga server:

  Exec tasks, one per connection: maps directly onto handle_conn(). The
  complication is socket handoff between tasks: on AmigaOS/bsdsocket each
  task is expected to have its own library base, and descriptors need
  explicit handoff. With CreateNewProc and NP_UserData the startup
  handoff is manageable.

  Multiplexed I/O (single task, WaitSelect on all sockets): clean in
  principle but undermined by the fact that AmigaDOS I/O calls (Read,
  Write, Seek) are synchronous and block the whole event loop. Ruled out
  unless async DOS packets are used.

  Async DOS packets + WaitSelect on combined socket+message-port mask:
  architecturally the most native AmigaOS approach. Issues ACTION_READ
  packets without blocking, waits for whichever of socket readiness or
  filesystem reply arrives first. No tasks needed. Higher implementation
  complexity. Deferred, not ruled out - the right long-term answer for
  the Amiga server.

Current choice: CreateNewProc, one per connection. The structure mirrors
the POSIX server (pthread per connection), the complexity is bounded, and
it can be replaced by the async approach later without touching protocol
or op handlers.


Amiga client: read-ahead
------------------------

AmigaDOS requests ~512 bytes per ACTION_READ. At 1 ms RTT that caps
sequential throughput at ~500 KB/s regardless of available bandwidth.
The client pre-fetches up to RA_SIZE (32 KB) per OP_READ and serves
subsequent ACTION_READ calls from the cache. This is the largest single
performance win for the backup/copy use case and was implemented with
~60 lines of new code in the handler (no protocol or server change).


Async write pipeline
--------------------

The dominant performance bottleneck for writes is round-trip latency: a
synchronous write model issues one OP_WRITE and waits for the server reply
before issuing the next. Over a 100 Mbit LAN with ~1 ms RTT this limits
write throughput to roughly (write size) / (RTT), well below line rate.

Both clients address this with a fire-and-forget write pipeline:

  FUSE client: do_rpc_nowait() sends an OP_WRITE and returns immediately
  without waiting for the reply. Pending write replies are drained by
  nfs_flush() and nfs_fsync(), which wait on a condvar signalled by the
  reader thread when all outstanding writes have been acknowledged.
  nfs_write() chunks the FUSE write into max_write-sized pieces; each
  chunk is sent as a separate OP_WRITE and queued in the pipeline.

  Amiga client: single-threaded, so true concurrency is not possible.
  Instead, drain_async_writes() is called at the start of do_rpc() before
  any non-write RPC, and explicitly in h_end() before OP_CLOSE, to drain
  any outstanding write replies before issuing the next operation.
  drain_async_writes() also reads the v6 max_payload field from each ACK
  and updates fc->max_write. h_write() chunks large AmigaDOS Write() calls
  into max_write-sized pieces, each sent as a separate OP_WRITE. Write
  errors are propagated as DOSFALSE from the Close() handler.

Write errors from pipelined writes are deferred: the application gets an
error on Close(), not on Write(). This matches the behaviour of buffered
POSIX I/O (fclose returns EOF on flush error) and is acceptable for the
target use case.

A write-coalescing buffer (accumulate writes up to RA_SIZE before sending)
was tried on the Amiga client and reverted. The buffer split application
Write() calls larger than RA_SIZE into multiple RPCs, doubling round-trips
for large-block writers. Applications writing in chunks at or above RA_SIZE
already maximise RPC payload; buffering cannot help them.


FUSE client: pipelining
-----------------------

The FUSE client has a dedicated reader thread and a per-seq pending slot
table so that FUSE op threads can send requests and wait on individual
condvars without serialising through a global mutex. This makes the FUSE
client a useful performance testbed for the pipelined protocol path, and
also makes it a correct concurrent client (FUSE dispatches VFS ops from
multiple threads simultaneously).


Filename length limits on Amiga
--------------------------------

fib_FileName in the NDK FileInfoBlock is 108 bytes (BCPL string: one
length byte, up to 107 chars). The Amiga client truncates incoming names
at 106 chars (sizeof(fib_FileName) - 2). Names longer than 106 chars are
silently truncated in directory listings; any attempt to open such a file
by the truncated name will return an error from the server.

Standard OFS/FFS enforces a 30-character name limit at the filesystem
level. Extended filesystems (PFS3, SFS) support up to 107 chars within
the same struct. The Amiga client does not enforce the 30-char limit
itself - it truncates at 106 - so names between 31 and 106 chars will
appear and work correctly on PFS3/SFS volumes but may confuse programs
that assume the standard 30-char maximum.

A POSIX server can serve files with names up to NAME_MAX (typically 255
chars). Names between 107 and 255 chars are silently truncated on the
Amiga side.


File sizes > 2 GB on Amiga
--------------------------

AmigaDOS file offsets are signed 32-bit. The Amiga client uses ULONG (32
bits) for file positions and sizes. Files larger than 2 GB will not work
correctly. This matches the inherent AmigaOS limit and is documented; the
POSIX server and the wire protocol are 64-bit clean.


Amiga client: path conventions in build_path()
----------------------------------------------

AmigaDOS uses '/' differently from POSIX. In an AmigaDOS path string:
- A leading '/' (or any '/' that follows another '/') signals "go up one
  level from the current point in the path". So "/" alone means parent,
  "//" means grandparent, and "/foo" means parent then into foo.
- '/' between two named components is a separator, as in POSIX.
- A trailing '/' after a named component is a directory-type hint ("lock
  this as a directory") not a navigation step, and is stripped.
- '.' and '..' are ordinary legal filenames on AmigaDOS. Neither has a
  special meaning in path resolution the way they do on POSIX.

build_path() implements this:
- Leading '/' characters are consumed one at a time, each calling
  strip_last() on the running path (stops at root).
- A lone '.' component after consuming any leading slashes is treated as
  a current-directory reference (no-op). This is a pragmatic concession
  to POSIX-aware tools that construct "./" paths; it is not an AmigaDOS
  convention.
- '..' is not treated specially. It is a legal AmigaDOS name that simply
  does not exist on any served POSIX filesystem, so operations on it fail
  with an appropriate error from the server.
- Trailing '/' is stripped only when preceded by at least one non-'/'
  character, preserving pure-slash strings for the leading-slash loop.


Permissions
-----------

Owner r/w/x bits are mapped in both directions via OP_CHMOD (protocol v2).
The capability name "unix" signals that all POSIX mode bits (owner, group,
other, setuid/setgid/sticky) are sent faithfully; "amiga" signals that
permissions are synthesised from fib_Protection. Group and other bits are
not mapped by the Amiga side.

  POSIX server -> FUSE client: real Unix mode bits (full 12-bit mode word)
  sent and applied correctly by the kernel via FUSE. A FUSE-to-FUSE mount
  using use=unix on both sides gets full round-trip permission fidelity.
  nfs_chmod() sends OP_CHMOD to the server (no-op if server_version < 2).
  chown/utimens return success without forwarding (no protocol ops for these).

  Amiga server -> any client: STAT and READDIR derive the owner r/w/x bits
  from fib_Protection (FIBF_READ/WRITE/EXECUTE, active-low); group/other are
  synthesised (055 for dirs, 044 for files). h_chmod() converts the received
  Unix mode back to fib_Protection via SetProtection().

  Any server -> Amiga client: fill_fib() converts the received Unix mode to
  fib_Protection (owner r/w/x only). ACTION_SET_PROTECT sends OP_CHMOD if
  server_version >= 2; falls back to silent success against a v1 server.

The delete bit (FIBF_DELETE) has no Unix equivalent and is not mapped; it
is always left clear (deletable) in both directions.


Timezone offset (TZOFFS)
--------------------------

AmigaDOS datestamps are local time; Unix timestamps are UTC. Without
correction, files show with a time that is offset by the TZ+DST difference.

Both the Amiga client and Amiga server accept a TZOFFS argument (seconds,
signed integer, e.g. 3600 for CET, 7200 for CEST, -18000 for EST). The
convention is: local time = UTC + TZOFFS.

  Amiga server: datestamp_to_unix() subtracts TZOFFS from the Amiga
  local time before sending the Unix timestamp to the client.

  Amiga client: unix_to_ds() adds TZOFFS to the received UTC timestamp
  before converting to an Amiga DateStamp.

Both adjustments must use the same TZOFFS value to round-trip correctly.
The value must be set by the user; there is no automatic TZ detection.

No timezone support exists in the protocol itself; the POSIX server and
FUSE client operate entirely in UTC and are not affected.


Locking
-------

Client-side locking is implemented in the Amiga client behind a compile-time
flag (#define LOCKING in amiga-client.c) and the LOCKING/S mount argument.
When enabled, the client maintains a per-mount lock table and enforces full
AmigaDOS conflict semantics in three directions:

  exact: EXCLUSIVE_LOCK conflicts with any existing claim on the same path;
  SHARED_LOCK conflicts only with an existing exclusive claim.

  descendant: an exclusive lock request is refused if any claim exists on
  any path within the target subtree.

An exclusive lock on a parent directory does NOT block access to paths inside
it. This matches standard AmigaDOS semantics: CreateDir() returns an exclusive
lock on the new directory, and the caller must be able to write files into it
while holding that lock. A "prefix blocks descendants" rule was removed after
it caused "object is in use" failures in copy all clone operations.

All operations that interact with the lock table: Lock/UnLock, DupLock,
FindInput/FindOutput/FindUpdate, End (close), CreateDir, DeleteFile, Rename.
File opens participate as implicit locks (FINDINPUT = shared,
FINDOUTPUT/FINDUPDATE = exclusive).

Cross-client enforcement (between separate connections to the same server)
is not implemented. The server has no lock table; two clients locking the
same path independently do not conflict. A future server-side implementation
would use an in-memory lock table (no OS-backed locking: AmigaDOS Lock()
has no non-blocking variant; Linux fcntl is a compile-time option at best),
keyed per file-handle token rather than per connection (multiple Amiga
applications share one connection). See TODO for the full design.


Test suite
----------

Two layers:

  run.lua (Lua, POSIX): integration tests that drive fizz-serve and
  fizz-mount end-to-end. 32 tests. FreeBSD note: run.lua uses
  /proc/mounts for mount detection (Linux-only); the local-server test
  mode is affected on FreeBSD.

  fizz-test.c (C99, portable): protocol-level client test program that
  compiles and runs on both POSIX and AmigaOS. 18 tests covering write,
  read, seek, truncate, rename, delete, mkdir, large file, nested dirs,
  many files, concurrent access.

  amiga-comparetree (AmigaOS, standalone CLI): walks two directory trees
  recursively and compares type, size, DateStamp, fib_Protection, and
  fib_Comment for every entry. Reports entries present in only one tree.
  Returns RETURN_OK (identical), RETURN_WARN (differences), or
  RETURN_ERROR (I/O error). Useful for verifying round-trip copies via fizz,
  including comment and flag preservation after xattr negotiation.

