virtio-9p (modern PCI 9P transport)
This is a provenance map for the in-tree virtio-9p driver: it cites the specs,
summarizes only the wire-format subset the code actually implements, and points
into the implementation. It is not a re-spec. The driver reuses the modern
split-ring transport seam introduced for virtio-net
(virtio-net) and the single-request-queue bring-up shape of
virtio-blk; this page covers only the 9p-specific parts.
Status: kernel fixture serving a host-directory export, read-only by default
and writable only on explicit request. The driver, its PCI discovery, its DMA
ledger, and the capability module above it are gated behind
any(feature = "qemu", feature = "virtio_9p_host_fixture") – either the qemu
feature that carries the whole QEMU device surface, or the composable
virtio_9p_host_fixture feature that carries just this fixture into a not(qemu)
build so the writable-9p path can co-boot with the Phase C userspace network
stack (see the
host-directory proposal
“Composable Fixture Feature” section). kernel/src/virtio.rs compiles in both
cases; a build with neither feature substitutes kernel/src/virtio_stub.rs
(the typed-negative kernel-network facade, which virtio.rs also includes for
its own not(qemu) builds). PCI discovery is diagnose_virtio_9p in
kernel/src/pci.rs, reached from the QEMU diagnostics path under qemu and from
the dedicated diagnose_virtio_9p_host_fixture startup route under
virtio_9p_host_fixture without qemu; the latter build routes
claim_pci_function + attach_dmapool_record_with_remapping through the minimal
kernel/src/device_manager/virtio_9p_fixture_claim.rs rather than the qemu-only
qemu_full Device Driver Foundation (DDF) ledger. What is implemented today is
transport bring-up, the 9P2000.L session handshake, a bounded read subset, and
a bounded write subset. Userspace reaches
them through two distinct kernel grant sources serving two distinct capability
types:
| Grant source | Cap types | Authority |
|---|---|---|
virtio_9p_root | Virtio9pDirectoryCap / Virtio9pFileCap | read only; every mutating method fails closed |
virtio_9p_root_writable | Virtio9pWritableDirectoryCap / Virtio9pWritableFileCap | read plus create/remove/rename and File write/sync |
Which one a process gets is fixed by the manifest at spawn and cannot change
afterwards. There is no rights flag and no method that turns a read-only cap
into a writable one, so a read-only export is not upgradeable at runtime – the
attenuation is structural, per
the capability model. A build with neither qemu
nor virtio_9p_host_fixture can obtain neither: it has no 9p cap module at all
and resolves both sources to kernel source `<name>` requires the qemu feature (or the composable virtio_9p_host_fixture feature), failing the spawn closed.
The fixture is dev/proof infrastructure and never a production authority; the
intended use is dev-loop payload injection and artifact
export, per
the host-directory proposal,
whose “Driver Boundary Decision” section records why this is a kernel-side
fixture (Option A) rather than a userspace driver process.
The driver lives in the virtio-9p section of kernel/src/virtio.rs
(Virtio9pDriver), and the protocol encoding/decoding is the transport-free
client codec capos_lib::ninep (capos-lib/src/ninep.rs).
1. Spec basis
- Device: virtio 9P transport device, modern (virtio 1.x) PCI transport.
PCI vendor
0x1af4; device0x1049(modern) /0x1009(transitional). IDs atkernel/src/pci.rs(VIRTIO_VENDOR_ID,VIRTIO_9P_MODERN_DEVICE_ID,VIRTIO_9P_TRANSITIONAL_DEVICE_ID; matched byPciDevice::is_virtio_9p). Exactly one function is bound, into the singleVIRTIO_9P_DRIVERslot; a second discovered function is refused rather than silently rebinding. - Authoritative specs:
- Virtual I/O Device (VIRTIO) Version 1.2, OASIS Committee Specification 01 (2022-07-01). Source: https://docs.oasis-open.org/virtio/virtio/v1.2/virtio-v1.2.html. Relevant sections: 4.1 (virtio over PCI bus), 2.7 (split virtqueues), 5.7 (9P transport device).
- 9P2000.L protocol, the Linux-flavoured 9P dialect.
Source: https://github.com/chaos/diod/blob/master/protocol.md.
The version string the driver negotiates is exactly
9P2000.L(capos_lib::ninep::VERSION).
- Reference: cross-checked against the QEMU 9p server (
hw/9pfs/9p.c) for the session-establishment behavior, and the Linux9pnet_virtiotransport for the descriptor-chain shape.
2. Wire format (implemented subset)
- Transport discovery and negotiation: unchanged from the shared modern-PCI
seam – vendor-specific capability walk, common/notify/ISR/device-config
region selection, and the reset ->
ACKNOWLEDGE->DRIVER-> feature-negotiation ->FEATURES_OK->DRIVER_OKhandshake. Seevirtio-net§2 for the seam itself (kernel/src/virtio_transport.rs). - Features:
VIRTIO_F_VERSION_1andVIRTIO_9P_F_MOUNT_TAGare both required and both selected; nothing else is accepted. A device that does not offer both fails bring-up closed (Ninep9pInitError::MissingRequiredFeatures). - Device configuration:
struct virtio_9p_config– au16tag length followed by that many mount-tag bytes. The driver maps exactly the length the transport capability advertises and validates the tag length against bothVIRTIO_9P_MAX_TAG_LENand the bytes actually present before reading the tag (Ninep9pInitError::MountTagOutOfRange). - Queues: one request virtqueue (index 0), a split ring built by the shared
Virtqueue::initialize. Size is clamped to the largest power of two not exceedingVIRTIO_9P_REQUEST_QUEUE_SIZEand the device maximum. Completion is polled (QueueInterruptPlan::polled); no MSI-X vector is programmed.Virtqueue::poll_used_within_nsenforces the five-secondVIRTIO_9P_COMPLETION_BUDGET_NSas either an absolute calibrated-monotonic deadline or its tick-derived absolute-deadline equivalent. The latter polls elapsed counts from the current CPU’s periodic LAPIC timer while preserving the syscall context’s interrupt-disabled state; tick-derived clock mode keeps that local periodic timer active on BSPs and APs by disabling nohz. Before a calibrated clock or periodic LAPIC timer exists, early bring-up instead usesVIRTIO_9P_COMPLETION_FALLBACK_SPIN_LIMITas a fail-closed backstop. The driver reports the selected mode and attributes expiry to the monotonic deadline, tick deadline, or spin backstop. - Descriptor chain: each 9p exchange is a two-descriptor chain – a
device-readable request segment followed by a device-writable reply segment,
each backed by its own dedicated DMA page so the two cannot alias
(
Virtio9pDriver::exchange). - Messages: session establishment plus bounded read and write subsets.
Tversion/Rversion– negotiatesmsizeand the9P2000.Lversion string. The requestedmsizeisVIRTIO_9P_MSIZE(4096); QEMU’s server rejects anything below itsP9_MIN_MSIZEof 4096 withRlerror(EMSGSIZE). The server may shrinkmsize, and every later message is bounded by what it actually granted – each operation rebuilds its codec from the granted value.Tattach/Rattach– establishes the share’s root fid (VIRTIO_9P_ROOT_FID) and yields its qid.Twalk/Rwalk– clones the attached root onto the scratch fid (VIRTIO_9P_SCRATCH_FID), with zero or one path elements only (Virtio9pDriver::walk_scratch). A partial walk is treated asENOENT; the server binds no fid in that case, so nothing is left to clunk.Tlopen/Rlopen–O_RDONLYfor files,O_RDONLY|O_DIRECTORYfor the root beforeTreaddir, andO_WRONLYon the write paths. No other mode is ever sent.Tgetattr/Rgetattr– entry size and the qid type bit, requested withGETATTR_ALL.Rreaddircarries an entry’s type but not its length, so a listing costs oneTgetattrper non-directory entry.Treaddir/Rreaddir– the root listing, read to exhaustion, resuming from the last entry’s offset..and..are dropped and a non-UTF-8 host name is skipped rather than rendered as something that is not the host’s name.Tread/Rread– file content, chunked bymsizeminus theRreadheader, bounded byVIRTIO_9P_MAX_READ_BYTES. A short reply ends the read. The complete requested byte range is checked against the protocol’su64offset space before transport use, and every chunk offset is computed with checked addition rather than clamped or wrapped progression.Tclunk/Rclunk– ends every transaction, including the error paths. A clunk can itself fail, and any failure downstream of aTwalkgoing on the wire leaves the fid’s state unknown, so the driver marks the scratch fid possibly-bound before it sends the walk and reclaims it at the start of the next one (Virtio9pDriver::release_scratch). Only the two replies that prove non-binding – a partial walk and anRlerrorfor the walk itself – clear the mark; the reclaim also clears it on anRlerrorto the clunk, since that is the server proving it does not hold the fid. Without this, one failed teardown would collide with every laterTwalkand wedge the export for the life of the boot. The fixed-fieldvirtio-9p-scratch-fid-readbackrecord is formatted byVirtio9pScratchFidReadbackSnapshotinkernel/src/virtio.rs. It reports current and high-water binding state plus saturating, reconcilable terminal outcomes for each walk and clunk attempt. Walk outcomes distinguish a confirmed bind, partial no-bind, serverRlerror, transport failure, and protocol failure; clunk outcomes distinguish confirmedRclunk, serverRlerror, and preflight, transport, or protocol failure that preserves the mark. Pre-walk reclaim and defensive unbound-release mismatch counts remain separate.Virtio9pDriver::walk_scratchandVirtio9pDriver::release_scratchupdate this observer without changing the walk or release decisions. The record contains no path, name, tag, fid value, capability, or file-content material, and carries no self-cost tuple of its own: its bytes, Console call, and due-check misses are charged into the family aggregate published onvirtio-9p-device-backing-readback.Rlerroris accepted in place of any success reply and surfaces asVirtio9pHandshakeError::ServerError.ENOENT,EEXIST, andEROFSmap to the typedNinep9pRequestError::NotFound/AlreadyExists/ReadOnlyShare; every other errno collapses toTransport.
- Write subset (
Tlcreate/Twrite/Tfsync/Trename/Tunlinkat), reached only from thevirtio_9p_root_writablecap types. The functions that encode these messages are theninep_create/ninep_write/ninep_fsync/ninep_rename/ninep_unlinkfaçade inkernel/src/virtio.rs; the read-only cap types contain no call into any of them.Tlcreate/Rlcreate– creates an entry in the directory a fid refers to and rebinds that same fid to the newly created open file. The driver walks the root onto the scratch fid first and treats it as consumed either way: onRlcreateit is an open file fid, on failure it is still the directory clone, so both outcomes stay covered by the existing possibly-bound mark. Sent withO_WRONLY|O_CREAT|O_EXCL, which makes “already exists” the server’s decision rather than a look-then-create window in the driver.Twrite/Rwrite– file content at an offset, chunked bymsizeminus the 23-byteTwriteheader (capos_lib::ninep::WRITE_REQUEST_HEADER_SIZE) and bounded in total byVIRTIO_9P_MAX_WRITE_BYTES. The codec refuses a decoded count larger than what was submitted – a caller advancing its offset by an inflated count would silently skip file content. A zero count on a non-empty submission isWriteStalledrather than an infinite resubmit loop. As on reads, an unrepresentable complete byte range is refused before transport use and later chunk offsets use checked addition. This prevents a valid prefix from reaching the host before the driver discovers that the rest of the requested range has no wire representation; it is not a rollback claim.Tfsync/Rfsync– sent withdatasync = 0(a full fsync, so metadata is durable too) on a fid openedO_WRONLY, so a read-only share refuses the sync rather than reporting one it did not perform.Trename/Rrename– always with the attached share root asdfid, so a rename cannot move an entry out of the export. POSIX rename would silently replace an existing target, but theDirectory.renamecontract fails closed on that, so an existence walk precedes the mutation. Check and rename are one driver-lock hold, so no other capOS caller can interleave. The writable QEMU proof seeds the target before boot, requires the exactNinep9pRequestError::AlreadyExistsrefusal, and verifies after QEMU exits that both source and target bytes are unchanged. A host-side create landing after the existence walk but beforeTrenamewould still be replaced. That residual is accepted for a single-writer fixture share and is not an atomicity claim.Tunlinkat/Runlinkat– removes a name from the attached root with flags0(noAT_REMOVEDIR). Operates on the root fid directly and binds no new fid, so it needs no scratch walk.
- Not implemented:
Tsetattr(soFile.truncatefails closed on both export variants),Tmkdir(soDirectory.mkdir/subfail closed – the export is a single flat directory), symlinks,Treadlink, and subdirectory traversal. Multiple concurrent tags,Tflush, andTauthare also out of scope – the fixture keeps one exchange in flight at a time and each façade operation is one complete transaction under the driver lock, which is why a single scratch fid suffices.
3. capOS mapping
-
Authority gate: the device is enumerated by the qemu-or-fixture-gated
diagnose_virtio_9p(kernel/src/pci.rs), which claims the PCI function through the device-manager ownership ledger asDeviceOwner::Virtio9pand attaches a DMAPool authority record. Underqemuthose two records come from the fullqemu_fullDDF ledger; undervirtio_9p_host_fixturewithoutqemuthey come from the minimaldevice_manager::virtio_9p_fixture_claimsurface (single-claim bookkeeping only – the DMA isolation is the always-builtdevice_dmabounce ledger in both builds). Manifests without a-device virtio-9p-pciline take thepci: virtio-9p device not foundpath, so this is a diagnostic and never a boot dependency. -
DeviceMmio: the common, ISR, notify, and device-config regions are mapped throughpci::map_bar_regionwith the same bounds validation the other virtio drivers use; the device-config region is mapped to exactly the advertised capability length because the 9p config is variable-length. NoDeviceMmiocapability is granted to userspace – the consumer reaches the share only through the typedDirectory/Fileinterfaces. -
Directory/File:kernel/src/cap/virtio_9p_fs.rsserves the existing interfaces over the driver’s subsets. No cap type holds a server-side fid – each call is a complete driver transaction (walk -> open -> op -> clunk) – so a dropped cap leaks nothing.Directory.openmints theFilethrough a result-cap transfer whose mode follows the directory’s authority: read-only results areCopy/SameSession, while writable results areNonTransferable/SameSession.virtio_9p_root(KernelCapSource::Virtio9pRoot, capnp ordinal 54) is minted bymount_root().Virtio9pDirectoryCapis stateless;Virtio9pFileCapholds a validated name and theNinepFileIdentityobserved atopen: qid, size, generation/data-version, mtime, and ctime. Each read walks the name once and compares that identity before and afterTreadthrough the same scratch fid. An atomic host rename therefore either leaves the read bound to the originally opened object or returnsNinep9pRequestError::IdentityChanged; bytes from a replacement object are never returned under the old cap.statremains the open-time snapshot, and opening the name again observes the replacement. The writable handle instead re-stats and uses live name-based reads because its own writes intentionally change the opened object’s identity. Classified with the other genuine read-only views (read_only_fs_root,installable_image_source):Copy/SameSession, so forwarding it shares only a read view.virtio_9p_root_writable(KernelCapSource::Virtio9pRootWritable, capnp ordinal 55) is minted bymount_root_writable().Virtio9pWritableDirectoryCapdelegateslistto the read-only cap and servescreate/remove/renameitself;Virtio9pWritableFileCapdelegatesread/closeand serveswrite/sync, and re-stats the share rather than reporting the size captured atopen, since the file changes under its own writes. Both the root and everyFileresult are deliberately not in the read-only-view classification group: they carry host-write authority over a share with one writer, so each is mintedNonTransferableand stays with the process that received it – the same reasoning that makes the disk-backed writable filesystem non-transferable.
Both sources are minted only when a device is actually bound; otherwise the grant resolves to an error and the spawn fails closed rather than handing out a
Directoryover nothing.The writer can also be minted for a child.
virtio_9p_root_writableis accepted as a raw kernel-sourceProcessSpawnergrant under theqemuorvirtio_9p_host_fixturefeature, so a parent that holds no writable share itself can hand one to a child it spawns. Every attenuation on that path is structural rather than a flag: the grant must be raw mode with a zero badge (both checked before the source is minted), its declared interface must equal the minted object’sDirectoryinterface, andkernel_source_spawn_holdleaves the holdNonTransferable, so the child cannot forward it onward. The QEMU proof also opens a writableFileand observes both an actual endpoint copy refusal and a raw-spawn refusal for that result. As a positive control, the child opens a fixture through a separately granted read-only root, copies that read-onlyFilethrough an endpoint to a helper, and the helper reads the pinned host bytes. A kernel built with neither fixture feature refuses the writable-root grant outright.One writer per boot, enforced in two places. Manifest validation rejects a second
virtio_9p_root_writablebootstrap grant across init and all services (capos-config/src/validation.rs); unliketerminal_sessionthere is no forward-from-init exception, because every bootstrap grant naming this source re-mints a fresh writable root rather than forwarding one. Bootstrap grants are not the only mint site, though – a spawn grant is runtime IPC that no manifest check can see – somount_root_writable()additionally latches on aWRITABLE_ROOT_MINTEDflag and refuses every committed mint after the first, whatever the site. Bootstrap resolution commits the claim with its mint. Spawn handling instead validates the exactDirectoryinterface before reserving the claim, retains that reservation through all later grant and process preparation, and commits it only when the child spawn commits. A failed request therefore returns the reservation because no writable cap was made reachable. Once committed, the latch is monotonic: it is not released when the holder exits, becauseFilehandles opened from a writable root are separateCapObjects that outlive it and keep a live write path to the share, so releasing on the root’s drop would admit a second writer alongside a still-writing first one. The cost is that the writer cannot be respawned within a boot; lifting that would need write-authority refcounting across both writable cap types, not aDropimpl. -
Interrupt: none. The fixture polls its used ring, so it deliberately claims no MSI-X route –interrupt_owner_for_device_ownermapsDeviceOwner::Virtio9ptoNone, and the bring-up diagnostic recordsinterrupt=none completion=polled-used-ring. NoInterruptcapability exists for this device. -
DMAPool: the driver owns a dedicated single-queue DMA ledger (device_dma.rs,Virtio9pPoolConfig), a distinct type from the virtio-blk and virtio-net pools, so a page handle minted against one never validates against another. It holds five pages: the three split-ring pages plus one request and one reply bounce page. The ledger enforces generation-checked page handles and scrub-before-frame-free ordering. Host physical addresses stay kernel-owned; none is exposed to userspace. The bulk read paths (Tread/Treaddir) decode out of a heap frame bounded by the negotiatedmsize, so a full-msizereply never needs anmsize-sized kernel stack frame.The identity-free
virtio-9p-queue-readbackrecord exposes the configured and effective queue depths, the five-page DMA ceiling, negotiatedmsize, the negotiated-msizetransient heap-frame ceiling, service-lifetime logical-length and allocated-capacity high-waters for transient heap frames, retained read results, and retained listing-name storage, a combined allocated-capacity high-water for the intermediate name-vector and returned entry-vector element backings, plus simultaneous transient-plus-retained allocated bytes, encoded result bytes, reply-scratch bytes, and the whole-operation allocated byte peak across the driver and capability-encoding phases, plus live and high-water in-flight submissions and cumulative submissions and completions read throughvirtio_9p_queue_account_snapshot. Logical lengths remain separate from the allocated backing reported byVec::capacity/String::capacity; reply scratch likewise uses its currently allocated backing length. Each listing-container charge is allocated element count multiplied by the Rust element layout size, with saturating arithmetic. While the name and entry vectors coexist during conversion, their charges are summed; other phases retain the largest live element-backing charge. The shared 8,192-byte formatter ceiling is checked against the 64-entry kernel layouts and by a host allocation test. Name bytes remain separately attributed. The existing logical-length fields remain comparable with earlier records, while the simultaneous value is capacity-derived. The whole-operation value is the greater of (a) that capacity-derived driver phase plus reply scratch already allocated before transport work and (b) retained read or listing capacity plus the encoded Cap’n Proto result and reply-scratch capacity after encoding. It also retains saturating mutually exclusive exchange outcomes for calibrated-monotonic, tick-derived, and spin-backstop poll expiry; malformed used-ring completion; DMA accounting failure; andDescriptorAlreadyActiverefusal. One aggregate structural-refusal counter covers oversized capability reads and writes, overlong path elements and mount tags, unrepresentable byte ranges, directory-entry overflow, and readdir-round exhaustion.Virtio9pQueueObserveremits once after bring-up, immediately when an in-flight or allocation-byte high-water changes, and after a completed exchange when the cumulative submission or completion total reaches an exact power of two. A directory listing accumulates its allocation peaks locally and publishes at most once when the complete operation unwinds, including an error return; it does not emit per retained entry orTreaddirround. Binary live occupancy remains visible in each snapshot but does not trigger output by itself; failure and refusal counters retain their own power-of-two sampling. The production formatter isVirtio9pQueueReadbackSnapshot; it uses fixed storage and carries no path, name, fid, tag, PID, capability, or file-content material. Observation does not change admission, submission, timeout, reclaim, or recovery behavior. -
Device-page backing readback (
virtio-9p-device-backing-readback):Virtio9pDeviceBackingSnapshotpublishes the whole-device resident backing that the queue record excludes.fixed_device_page_backing_bytesisdma_page_budgetmultiplied bydma_page_bytes– 20,480 bytes from the kernel’s fixed five-page pool (three split-ring pages plus the request and reply bounce pages) – andwhole_device_resident_backing_bytes_high_wateradds that value to the simultaneous transient-plus-retained capacity peak republished on the same line.fixed_backing_derivation=code-owned-page-budgetmarks both as structural derivations ofVIRTIO_9P_KERNEL_DMA_PAGE_BUDGET, not live physical-memory measurements, allocator-overhead accounts, or per-caller charges.resident_scope=fixed-pages-plus-driver-heapnames what “resident” covers: device-owned backing only. The reply scratch and the encoded Cap’n Proto result are charged to the calling process’s ring (EffectiveReplyScratchoverRingScratch::replyinkernel/src/cap/ring.rs, bounded byREPLY_SCRATCH_BYTES_MAX), not to the device, so they are outside this total. That is why the combined value reads below theoperation_hwwhole-operation peak on the paired queue record, which does include both – in the read-only proof, 25,600 against 70,656. Allocator-internal overhead, physical frames beyond the fixed pool, and QEMU-side host cost stay excluded. It is a separate record rather than extra queue-record fields because the saturated queue line already sits at its Console-call length bound with the established headroom; both records are emitted from one observer sample at the same call site, so each queue record has a matching backing record. It carries no identity material and changes no admission, submission, timeout, reclaim, or recovery decision. -
Readback self-cost (
readback_coston the device-backing record):Virtio9pReadbackOutputCostincapos-lib/src/virtio_9p_readback.rsrenders saturatingwithheldPublications,formattedRecordBytes, andconsoleCallscounters as one base-36 comma tuple. It is a single aggregate for the whole fixture readback family rather than a per-record figure:readback_cost_scope=queue,device-backing,scratch-fidnames the three records it covers, and it rides this line because the queue line has no length budget left and the scratch-fid record comes from an independent observer. The tuple carries no identity material, allocates nothing, opens no new interrupt-disabled section, and changes no handshake, submission, timeout, reclaim, refusal, or recovery decision. What each counter charges, what stays uncharged, when a cost-only publication becomes due, and the remaining accounting gaps are governed by the diagnostic output descriptor inventory, which is authoritative for those rules. -
Fail-closed / validation rules: bring-up fails closed and leaves the driver unpublished on any of – missing required features, rejected
FEATURES_OK, an undersized or oversized device-config/mount-tag, an absent or too-small request queue, or a failed handshake. Every reply is bounded before decoding: the device-reported written length must be at least a 9p header and no larger than the calling operation’s own reply buffer – the 256-byte stack scratch for the fixed-size messages, themsize-bounded heap frame forTread/Treaddir– and the reply’s size prefix is cleared before each exchange so stale page contents cannot be decoded as a fresh reply. Thecapos_lib::ninepcodec then independently validates the frame against the negotiatedmsize, the declared size, the expected tag, and the expected reply type before exposing a field. The host-supplied mount tag is rendered as printable ASCII only, so a hostile tag cannot forge or split a diagnostic line.The capability layer adds four more fail-closed rules. Traversal: a name reaching
Directory.openmust be exactly one ordinary path element –validate_9p_namerejects empty,.,.., any/, an interior NUL, and anything pastVIRTIO_9P_MAX_NAME_LEN. It guards every façade entry point, read and write alike, and combined with the single-elementTwalkand the root-anchoredTrenamedfidit means no mutation can land outside the share. Mutation: on avirtio_9p_rootcap,Directorymkdir/remove/sub/create/renameandFilewrite/truncate/syncall fail closed in the cap layer, and those types call nothing in the driver’s write façade. On avirtio_9p_root_writablecap,mkdir/sub/truncatestill fail closed for want ofTmkdir/Tsetattr, andopenstill refusesCREATE/TRUNCATEbecauseDirectory.createis the fail-closed creation path (honouring them would report success for an operation that did not happen). Defense in depth: a share QEMU exportedreadonly=onrefuses every mutation at the server withEROFS, so an over-broad manifest still cannot write a share the host meant to protect. Stable read identity: a read-onlyFilecompares its open-timeNinepFileIdentitywithTgetattron the same fid immediately before and after each read. A host-side rename, rewrite, or metadata change rejects the operation instead of mixing the open-time size with replacement bytes. Bounds: a host directory beyondVIRTIO_9P_MAX_DIR_ENTRIES(64) fails closed rather than growing an unbounded kernel allocation from outside the TCB; a read pastVIRTIO_9P_MAX_READ_BYTESand a write pastVIRTIO_9P_MAX_WRITE_BYTESare refused rather than silently short-read or clamped into a short write the caller cannot distinguish from host refusal. A non-empty read or write whose last requested byte would exceedu64::MAXreturns the distinctNinep9pRequestError::OffsetOverflowbefore any request for that range is issued.Entry names and file bytes are host-supplied, i.e. from outside the TCB. The cap layer passes them through unaltered (
Text/Datain the ABI); every renderer is responsible for the escape seam, and the consumer demo routes each name throughcapos_rt::console_text::escape_console_text_bounded. -
QEMU-emulable vs hardware-only: entirely QEMU-emulable. virtio-9p is a paravirtual device with no hardware counterpart, so there is no hardware-only residue. The driver’s kernel host unit tests – including the readback formatters’ saturated Console-call-ceiling bounds – run under
make test-kernel-virtio-9p-fixture, notcargo test-kernel: the default feature set substitutesvirtio_stub.rs, sovirtio.rsand its tests are not compiled there at all. The bring-up proof ismake test-virtio-9p-bringup(tools/qemu-virtio-9p-smoke.sh,manifests/system-virtio-9p.cue), which asserts the negotiatedmsizeand version, the mount tag read back from device config, and the attach qid’s directory type bit. The end-to-end capability proof ismake test-virtio-9p-fs(tools/qemu-virtio-9p-fs-smoke.sh,manifests/system-virtio-9p-fs.cue, consumerdemos/virtio-9p-fs, share built bytools/mk-virtio-9p-share.sh): a guest process lists the host share, reads exact host bytes back whole and at an offset, and observes every mutating and traversing method fail closed. The share’s pinned names, sizes, and position-dependent byte pattern are what make the read-back a proof the guest saw the host’s bytes rather than a compiled-in constant. The guest also opens a 36-byte valid wasm object, the host atomically replaces its name with a 4097-byte object carrying the same executable prefix, and a host-written completion marker synchronizes the replacement before the guest reads the oldFilecap. The read must fail on the changed identity. Two other fixture names differ only by escaping – one carries a real TAB, the other the literal characters\andt– so the smoke also proves the render seam keeps distinct host names distinct. The guest additionally requests a 4096-byte read beginning atu64::MAX - 1; the capability must expose the distinct offset-overflow refusal before transport use.The read and write harnesses also require a complete, untruncated
virtio-9p-queue-readbackrecord with nonzero post-handshake submission and completion attribution, a nonzero structural-refusal count from the existing offset-overflow proof, and no more than 32 readback records per boot. This proves observer wiring and logarithmic successful-traffic sampling over the normal fixture; it does not inject timeout, mismatch, accounting, or stranded-slot failures and therefore establishes no recovery guarantee.Both harnesses then reconcile the published
readback_costtuple against the boot’s own Console transcript throughtools/virtio-9p-readback-cost-check.sh. The diagnostic output descriptor inventory is authoritative for what that reconciliation requires.The write proof is
make test-virtio-9p-write(tools/qemu-virtio-9p-write-smoke.sh,tools/qemu-virtio-9p-fsync-crash-smoke.sh, consumerdemos/virtio-9p-write), which runs four stages sequentially through recursive make so their builds cannot shareiso_root/. The fourth stage contains two isolated boots, for five boots total:-
test-virtio-9p-write-rw(manifests/system-virtio-9p-write.cue, share exportedreadonly=off) – the guest creates an entry, refuses a duplicate create, writes 6144 bytes in two calls, fsyncs,stats, reads back, renames, refuses a rename onto the pre-boot fixture entry with the exactNinep9pRequestError::AlreadyExistsapplication error, removes, and observesmkdir/sub/truncate/open(CREATE|TRUNCATE)and every traversing mutation fail closed. The firstFile.writesubmits 4096 bytes at offset 0, which exceeds the negotiated 4073-byteTwritepayload and forces the driver to resubmit the remaining 23 bytes at wire offset 4073. The guest then uses the returned count, 4096, as the offset for a 2048-byte second call. A second 4096-byte write beginning atu64::MAX - 1must return the distinct offset-overflow refusal before transport use. After QEMU exits, the harness verifies the result on the HOST side of the share: the renamed file must exist with exactly 6144 bytes matching the guest’s position-dependent pattern, the pre-rename and removed names must be gone, no../escaped.*may have appeared beside the share, and the pre-boot collision target must retain its exact pinned bytes. Together, the source comparison and target comparison prove that the refused collision changed neither file. That host-side check is what makes this a write proof rather than a guest self-report. The existing renamed-output pattern and absence checks have mutation evidence; the new collision-target byte comparison is direct host evidence and is not claimed as separately mutation-tested. -
test-virtio-9p-write-hostro(manifests/system-virtio-9p-write-hostro.cue, share exportedreadonly=on) – the SAME writable cap over a protected share. All five mutations must be refused withEROFSwhilelist/open/readkeep working (a share that merely failed to attach would refuse everything and prove nothing), and the host share must be unchanged afterwards. The guest matches every application exception against the driver’s exactReadOnlySharetext before emitting a typed summary, and the harness requires both that summary and the writable grant source. The stage therefore cannot pass on a read-only cap: that cap refuses in the cap layer, with different wording, and never reaches the driver. After QEMU exits, the harness compares the pinned fixture bytes and rejects any of the proof’s mutation output names rather than treating name survival as byte immutability.Two precisions about what stage 2 refuses where.
create,remove, andrenameare refused on the wire, atTlcreate/Tunlinkat/Trename.writeandsyncare refused earlier, at theTlopen(O_WRONLY)that precedes them, so those two messages never reach the server in this stage – what is proven is that a write-intent open fails closed, which is the gate that matters, not that QEMU rejects aTwrite. And the stage pins QEMU’s choice of errno: another server answering the write-intent open withEACCESrather thanEROFSwould map toTransportand fail this gate loudly rather than degrade silently. -
test-virtio-9p-write-spawn(manifests/system-virtio-9p-write-spawn.cue, share exportedreadonly=off) – the writable cap reaching a child through a raw kernel-sourceProcessSpawnergrant. The parent holds a read-only view for the positive delegation control but no writable share, so the child’s writer is the only one this boot. The child asserts the exactDirectoryinterface ids on arrival, writes 4096 bytes in two chunks (the second at a non-zero offset),syncs,stats and reads back. It then opens both a writable and a read-onlyFile: endpoint copy and raw spawn refuse the writable result, while a helper receives the read-only result by endpoint copy and reads the pinned fixture through it. Raw spawn also refuses the writable root. Before the successful spawn, the parent submits raw-wire probes for a wrong writable-root interface and for a malformed later grant; both fail without consuming the one writer. It also checks non-raw mode, a non-zero badge, and a second mint after the successful child exited. After QEMU exits, the harness verifies the child’s bytes on the HOST side of the share.Each refusal is matched against the kernel’s own exception text rather than against
is_err(), because the negative cases here exercise different mechanisms that anis_err()check could not tell apart – the pre-slice behavior, a single blanket “unsupported kernel source” rejection, would have failed all of them while proving none. For the same reason the stage does not grep the granted source the way stages 1 and 2 do: that diagnostic is emitted only for bootstrap grants, and the writable root is minted only at spawn time. What pins writable provenance instead is the host byte check, which the parent’s read-only cap cannot satisfy at all.Mutation-tested in both directions: disabling the
WRITABLE_ROOT_MINTEDlatch makes the second mint succeed and fails the gate, and perturbing the child’s payload pattern fails the host comparison while the child’s own self-consistent read-back still reportsmatch=ok– which is the point of verifying on the host rather than trusting the guest. Changing the writableFileresult hold back toCopymakes the actual endpoint transfer succeed and fails the gate before the read-only control call. A valid child still writing the 4096-byte host payload after each pre-commit raw-wire refusal proves those failures returned the writer reservation. -
test-virtio-9p-write-fsync-crash(manifests/system-virtio-9p-write-fsync-crash.cue, two shares exportedreadonly=off) – the positive boot creates and writes a distinct 4096-byte payload, invokesFile.sync, emits its arming marker only after the call returns, and spins while the harness force-kills QEMU. The harness then requires the host file to contain the complete expected payload. A second boot uses a host-seeded selector to take the negative-control arm: it emits a distinct marker afterFile.writereturns but before invokingFile.sync, then spins at that point until the harness force-kills QEMU. The marker pair proves that the two kills landed on opposite sides of the sync call rather than letting either transcript satisfy the stage.On the current QEMU local 9p backend, the pre-sync control file is also present and byte-complete. Killing the QEMU process does not discard the host kernel’s filesystem page cache, so post-exit host readback cannot distinguish the synced and unsynced cases. The stage reports this as a bounded negative finding and does not promote the positive survival into a durability proof. This adds no driver path or ordering change: it exercises the already-landed
ninep_writethenninep_fsyncorder through the existing writableFilecapability.
The composable-fixture co-boot proof is
make test-virtio-9p-net-coboot(tools/qemu-virtio-9p-net-coboot-smoke.sh,manifests/system-virtio-9p-net-coboot.cue). It builds ONEnot(qemu)kernel under bothcloud_virtio_net_userspace_sustained_receive_pool_proofandvirtio_9p_host_fixtureand boots it with a virtio-net device and areadonly=offvirtio-9p share, asserting that the Phase C userspaceNicpath and a writableDirectoryboth serve in the same boot: the writable-9p write proof runs to completion (its 6144-byte result verified on the host side of the share) while the userspace NIC shim drains its sustained receive pool (link=up,frames_received=2). This is the proof that the fixture composes with the Phase C userspace network stack for the Host-Accessible Persistent Task Backend milestone without enabling the competingqemuvirtio-net/DDF owner. Because anot(qemu)kernel has no compiledisa-debug-exitshutdown path, both services run to completion and the VM terminates on the harnesstimeout(exit 124).Known coverage residuals. The smoke proves an absent-entry lookup does not wedge the export, but it does not exercise the scratch-fid reclaim itself: a compliant server answers that lookup on the
ENOENTpath, where no fid is bound, so the assertion would pass with the reclaim removed. The paths the reclaim exists for – a failedTclunk, an over-longRwalk, an undecodable frame, a post-completion accounting failure – require a hostile or faulty 9p server that QEMU will not produce. Closing this needs a proof-only feature that injects the failure, in the shape ofstorage_writable_recovery.Three residuals belong to the write subset specifically:
- Server short-write and
WriteStalledhandling are unproven. The 4096-byte first call now proves chunk resubmission under the negotiated 4073-byte payload limit, including the advancing wire offset and the guest-visible returned count. QEMU accepts every submitted chunk in full, however, and never returns zero. Exercising resubmission after a non-zero short count and the zero-countWriteStalledrefusal requires a hostile or fault-injected server. Tfsyncdurability remains unproven after a forced-poweroff negative control.test-virtio-9p-write-fsync-crashproves that a known payload is complete on the host after QEMU is force-killed following a returnedFile.sync. Its paired control is force-killed afterFile.writereturns but beforeFile.syncis invoked, and that payload is also complete. QEMU process termination leaves the host kernel and its page cache alive, so reading the same host filesystem after exit cannot isolate whatTfsynccontributed. The existing proof still shows that the call is issued, accepted on a writable share, and refused withEROFSon a read-only one; the new stage bounds why this fixture cannot establish crash durability. Closing the residual needs a model that makes the storage boundary and cache-loss event observable rather than another post-exit read of the same live host filesystem.- The
Trenameexistence check is not atomic against the host. See theTrenameentry in §2. A target present before the guest request is proven to produceAlreadyExistswith source and target bytes intact, but a host-side create in the check-to-rename window would still be replaced. Only capOS-side callers are serialized, by the driver lock.
The non-
qemurefusals are covered separately, by a host-side policy test rather than by a run. The decision itself iscapos_config::virtio_9p_writable_source_refusal(capos-config/src/manifest.rs): a pure function taking the source, the mint site, and whether a virtio-9p fixture feature is compiled in. Both mint sites call it ahead of their source match, in every build, passingcfg!(any(feature = "qemu", feature = "virtio_9p_host_fixture"))– bootstrap resolution incap/mod.rsandbuild_child_capsinprocess_spawner.rs. On the spawn-grant path that call is the only refusal for this source; the bootstrap match keeps acfg(not(any(...)))arm only because it is exhaustive overKernelCapSourcewith no catch-all. That arm does not restate the refusal – it is anunreachable!asserting the guard already returned. Restating it there would leave bootstrap refusing on its own after a mutation to the shared function, which would falsify the policy test’s claim to decide production behavior.Passing the feature state as an argument, rather than reading
cfginside the function, is what makes this testable:cargo test-configevaluates the production decision with the feature off, which no test can do by compiling the kernel, since every stage ofmake test-virtio-9p-writebuilds--features qemu. The tests assert both directions (refused without the feature, admitted with it), that the policy passes every other source through untouched, and that each refusal names the source and the missing feature, differs per mint site, and is not the blanket “unsupported kernel source” fallthrough. That last part matters because a build withoutqemuwould refuse the grant either way, via the fallthrough arm, so only the specific text distinguishes deliberate qemu-gating from the source merely not being wired up. Mutation-tested at both sites: flipping either branch of the shared function – spawn-grant or bootstrap – to admit the source failscargo test-config. Under the bootstrap mutation the non-qemukernel still compiles, which is exactly what the assertion arm buys: that site no longer refuses on its own, so the shared function is the only thing deciding.What that does not establish is a runtime observation. No booted kernel exercises the refusal, so the claim is that the production kernel’s own decision function refuses – not that a running production kernel was seen refusing.
One accepted limitation is not a proof gap but a scope decision:
File.writebounds the payload per call but not theoffset, so a writable-cap holder can write a byte at a very large offset and make the host materialize a sparse file. That is within the authority the cap already grants (it can create, write, and unlink entries in the share), and the fixture is a QEMU-hosted dev-loop share pointed at a dedicated scratch directory. A production writable route would need a quota, which is one more reason this is not one. -
Related
- Implementation:
kernel/src/virtio.rs(Virtio9pDriver,Virtio9pDma,diagnose_virtio_9p_transport, and theninep_*façade),kernel/src/pci.rs(diagnose_virtio_9p,PciDevice::is_virtio_9p),kernel/src/device_dma.rs(Virtio9pPoolConfig,SingleQueueDmaLedger). - Capability layer:
kernel/src/cap/virtio_9p_fs.rs(Virtio9pDirectoryCap,Virtio9pFileCap,mount_root;Virtio9pWritableDirectoryCap,Virtio9pWritableFileCap,mount_root_writable,WRITABLE_ROOT_MINTED); grant-source resolution inkernel/src/cap/mod.rs; the spawn-grant arm inkernel/src/cap/process_spawner.rs; the single-writer bootstrap check incapos-config/src/validation.rs; source names incapos-config/src/manifest.rs. - Consumer proofs:
demos/virtio-9p-fs,tools/qemu-virtio-9p-fs-smoke.sh,manifests/system-virtio-9p-fs.cue;demos/virtio-9p-write,tools/qemu-virtio-9p-write-smoke.sh,manifests/system-virtio-9p-write.cue,manifests/system-virtio-9p-write-hostro.cue;demos/virtio-9p-write-spawn-parent,demos/virtio-9p-write-spawn-child,manifests/system-virtio-9p-write-spawn.cue. Shared fixture share:tools/mk-virtio-9p-share.sh. - Protocol codec:
capos-lib/src/ninep.rs; fuzz targetfuzz/fuzz_targets/ninep_reply_decode.rs. - Design: Virtio-9p Host Directory Passthrough.
- Shared transport seam:
virtio-net,virtio-blk.