Files
danos/library/protocol/block/block-protocol.zig
T
Daniel Samson f1bdce25e0 block: per-sender range confinement — V2a mechanism
The block protocol gains define_range (appended, numbers hold): confine the
process named by `badge` to blocks [base, base+count). usb-storage keeps a
per-badge range table and, in read/write, translates volume-relative LBAs
(base added) and refuses any transfer past the volume end. geometry returns
the confined size, so a filesystem mounts against what it may actually touch.

The security seam (decision 4, settled): the clamp lives at the PROVIDER, so
a channel carries exactly the authority it grants — handing a filesystem the
whole disk plus a base offset would let it reach the neighbouring partition.
The gate: a confined caller may NOT call define_range, so a filesystem cannot
widen its own range or confine anyone; only an unconfined party (the volume
manager, whole-device) may. The volume manager defines a filesystem's range
before handing it the channel, so the ordering holds by construction.

Default (no range for a badge) is the whole device — behaviour-neutral for a
single-volume boot and what the volume manager itself uses to probe
partitions. The range table is declared through bounds.md as a runaway
detector (ours, refuse at limit), not a real-partition cap. Neutral:
fat-mount, usb-storage, iommu-usb-storage green. The discrimination fixture
(a confined process reads past its range and is refused) follows next.
2026-08-09 17:13:26 +01:00

92 lines
4.5 KiB
Zig

//! The block-device wire protocol — what a filesystem (the FAT server) says to a
//! block driver (usb-storage) over `/protocol/block`. Defined through the
//! envelope, so every packet begins with the folded `Header`.
//!
//! **`Header.target` is always 0 here**: a block driver instance serves exactly
//! one device over its own endpoint, so there is no object within the peer to
//! address. A driver that later fronts several volumes gives them target ids and
//! `enumerate` lists them; nothing else about the protocol changes.
//!
//! Data path: read and write move whole blocks to or from a **caller-owned DMA
//! buffer**, named by its physical address — the same physical-address handoff
//! usb-storage already uses toward the controller, one layer up. So a 512-byte
//! sector never has to cross the packet floor; only the small request / reply
//! parts do. Under an enforcing IOMMU the buffer's physical addresses are only
//! reachable by the device once the filesystem has `attach`ed the buffer's
//! capability (the block server forwards it to the controller); see
//! docs/driver-model.md.
const envelope = @import("envelope");
/// The answer to `geometry()`.
pub const Geometry = extern struct {
block_size: u32, // bytes per block (512)
_padding: u32 = 0,
block_count: u64, // total blocks
};
/// `read(lba, count, physical)` / `write(...)`: move `count` blocks between the
/// device and the caller's DMA buffer at `physical`.
pub const Transfer = extern struct {
lba: u64,
count: u32,
_padding: u32 = 0,
physical: u64, // caller's DMA buffer physical address
};
/// How many blocks a transfer actually moved.
pub const Transferred = extern struct { count: u32 };
/// `define_range(badge, base_lba, block_count)`: confine the sender identified by
/// `badge` to blocks `[base_lba, base_lba + block_count)`. The volume manager
/// calls this for each filesystem process it hands a channel to — the badge is
/// the filesystem's kernel-stamped task id, and the range is the partition it
/// mounts. A confined sender's read/write LBAs are then volume-relative (the
/// driver adds `base_lba`) and a transfer past `block_count` is refused. A
/// sender with no range is unconfined (the whole device), the default until the
/// volume manager defines one. The clamp lives at the provider because a channel
/// must carry exactly the authority it grants (storage-architecture.md): handing
/// a filesystem the whole disk plus a base offset would let it reach the
/// neighbouring partition. **A confined caller may not call this** — a filesystem
/// cannot redefine its own range and escape; only an unconfined party (the
/// volume manager) confines others.
pub const DefineRange = extern struct {
badge: u32,
_padding: u32 = 0,
base_lba: u64,
block_count: u64,
};
pub const Protocol = envelope.Define(.{
.name = "block",
.version = 1,
.operations = &.{
.{ .name = "geometry", .reply = Geometry },
.{ .name = "read", .request = Transfer, .reply = Transferred },
.{ .name = "write", .request = Transfer, .reply = Transferred },
// flush(): commit any device write cache to stable media (no data
// transfer). A filesystem calls this to make prior writes durable —
// before power-off, so a shutdown-time write isn't lost in the USB flash
// controller's cache.
.{ .name = "flush" },
// attach(): the caller's DMA-region capability rides the call's cap
// slot; the block server forwards it to the controller so the buffer's
// physical addresses (named in later read/write) are reachable by the
// device under an enforcing IOMMU. Call once per buffer before using it.
.{ .name = "attach" },
// detach(): the reverse — the same region capability rides the cap slot
// (the caller still holds its handle; the kernel matches the region) and
// the buffer leaves the device's domain. Every grant a live process
// makes is revocable by the granter while alive; death remains the
// mechanical backstop (storage-architecture.md, the lifecycle rule).
.{ .name = "detach" },
// define_range(): confine a sender to a block sub-range — the partition
// it mounts. See `DefineRange`. Appended, so every verb above keeps its
// number.
.{ .name = "define_range", .request = DefineRange },
},
});
pub const Operation = Protocol.Operation;
pub const message_maximum: usize = Protocol.message_maximum;