A 7-dimension adversarial review of the engine, tool, and routing found
nine real defects (host tests + the in-VM drill missed them). Fixed:
- geometryOf now rejects a crafted VBR whose cluster shift exceeds the
exFAT ceiling (bytes+sectors shift > 25) or whose cluster_count exceeds
the spec max (0xFFFFFFF5) — either would overflow the engine's u32
cluster-byte / cluster-bounds arithmetic and panic under ReleaseSafe on
untrusted removable media. validCluster/allocateCluster widened to u64,
and writeFile's clusters_needed widened, for a >4 GiB file near the u32
offset boundary.
- writeFile no longer claims valid_data_length = size unconditionally: a
sparse write past a foreign file's old valid boundary now zero-fills the
skipped gap on disk, so a read there returns zero, not stale bytes.
- ensureDirCapacity rewrites a grown subdirectory's own DataLength, so a
spec-compliant reader that bounds a directory by DataLength sees the new
entries (danos itself bounds by the end marker, but chkdsk / other OSes
do not).
- make-exfat-image lays the allocation bitmap across as many clusters as
it needs; a >128 MiB image (whose bitmap exceeds one cluster) was
self-inconsistent. Verified: the engine mounts+reads both the 48 MiB
fixture and a 256 MiB image.
Documented (not fixed here — a shared vfs-layer limit, like the u32
offset cap): non-ASCII names fold to '?', the same as the FAT engine.
New host tests pin each fix (crafted-VBR rejection, sparse-gap zero,
subdir-grows-and-records-size). Full suite 131/131, bounds green.
Pure stdlib, no mkfs.exfat: writes a Main Boot Sector + its boot-region
checksum + a backup region, the 32-bit FAT, an allocation bitmap, an
up-case table (with its checksum), and a root directory carrying the
bitmap/up-case/label entries plus a seeded HELLO.TXT file set. --serial
sets the volume serial (the content id-path); --verify re-reads the VBR,
recomputes the boot checksum, and validates each file set's checksum.
Cross-checked against the engine: the Zig exFAT engine mounts a
Python-produced image and reads HELLO.TXT through a case-insensitive
resolve ("exfat hello danos"), proving the tool and engine agree on the
layout — VBR, geometry, bitmap, up-case, and the entry-set checksum. No
image is committed; the drill (step 8) generates one per run.
The two-volumes case proves multiple DEVICES; this proves multiple
volumes on ONE device. make-partitioned-image.py lays several FAT32
partitions (each from make-fat-image) behind a classic MBR; the new
partitioned-volume case attaches one such disk (two partitions,
da7a0001 at lba 2048, da7a0002 at lba 83968) as a single usb-storage
device.
partition.allVolumes walks the table and the manager spawns a confined
fat per partition on the SAME block channel, each clamped to its own LBA
range by usb-storage's per-badge range table — so partition B's fat
cannot read partition A's blocks. The case asserts two mount lines at
two distinct non-zero base_lbas on one device; against the pre-uncap
allVolumes (S3 step 2, capped to one partition) only da7a0001 mounts.
No image is committed — the disk is generated per run. Full suite
130/130 (128 + two-volumes + partitioned-volume).
A second usb-storage device (a generated data volume, serial da7a0001,
an empty FAT with no /system) plugged in beside the boot volume: the
volume manager adopts both devices and spawns a confined fat per volume,
each mounted at its own content id-path.
The test surfaced a real coexistence bug. Every filesystem bound the
single "vfs" contract name under /protocol; the second volume's fat lost
the race, service.run refused-and-exited on the held name, and that
volume never mounted. Clients don't reach filesystems by that name —
fs_resolve routes a path to its backing endpoint through the kernel
mount table by prefix — and nothing consumes "vfs", so the fix is to
bind no shared name: the harness's service_name now defaults to null.
This is the "this fades" the harness comment anticipated for the
volume-manager era; a filesystem's endpoint still serves as its mount
backend without a name.
fat logs "is a data volume" for the non-system branch so the test can
positively assert content-based detection. make-fat-image gains
--serial/--label (default unchanged) so a second image gets a distinct
id-path; the data image is generated per run, never committed. The case
fails against the pre-fix harness (the data volume's fat exits on the
refused bind) — toggle-demonstrated.
Full suite 129/129 (128 + two-volumes); the single-volume path is
unaffected by dropping the vestigial name bind.
Multi-volume makes the mount table the bottleneck: each volume installs
one id-path mount and the system volume two FHS rewrites, so at the
volume manager's maximum_volumes (16) the old cap of 8 is far too low.
Raise maximum_mounts to 32 (headroom over the ~20-mount worst case) and
declare its bounds block; drop it from the bounds allowlist.
Fix a latent bug the higher pressure would expose: installMount silently
dropped a mount when the table was full, and mountBackend returned true
anyway — a full table was reported as a successful mount. installMount
now returns whether it placed the mount, and mountBackend propagates a
false so the mounting filesystem's harness logs "could not mount
<prefix>". At-limit is now a refusal that is observed, not a silent
success. (The full-table path has no host unit test: vfs.zig's tests are
not wired into the host aggregate — its import graph reaches the
freestanding kernel — so the correction rests on the propagated return
and the truthful bounds block.)
max_interfaces was 4. A composite device — a headset, a webcam with audio, a
dock, a multifunction printer — routinely has more, and the fifth did not
merely go missing. parseConfiguration's cap branch had no `else`, so when the
count was reached `current` kept pointing at interface 3 and the fifth
interface's endpoint descriptors were appended to interface 3's array. A
class driver bound to interface 3 could then be handed an endpoint belonging
to something else entirely, and subscribe or bulk-transfer on it. The
alternate-setting arm one line above cleared `current` correctly, which is
what the cap branch should have done.
Interfaces are now counted from the block in a first pass and allocated to
exactly that number, so the ceiling is bNumInterfaces' u8 — the USB
specification's. The missing `else` is added too, though after this the bug
is unreachable by construction: interface_count cannot reach interfaces.len
mid-parse when the list was sized from the same walk.
max_configured_endpoints was max_interfaces * max_endpoints_per_interface =
16, a derived guess that moved whenever either input moved. It is now 31,
which is the xHCI specification's own limit: a Device Context holds a slot
context plus at most 31 endpoint contexts, because the Context Entries field
addressing them is 5 bits.
max_endpoints_per_interface stays at 4 with its reason recorded — the
usb-transfer wire protocol reports exactly max_reported_endpoints (4) per
interface, so widening it alone would change nothing a class driver sees.
Lifting it is a protocol change.
No direct test, and that is written down as open question 5 rather than
glossed. The parser is pure and wants a host unit test, but
usb-xhci-library.zig imports memory, mmio and time so it cannot be a
standalone test root, and QEMU offers nothing that reaches the path — the
largest device available is usb-audio,multi=on at 2 interfaces and 211
bytes. The alternate-setting path that shares the same `current = null`
logic is exercised by that device.
Suite 116/116.
The driver read the first 512 bytes of a configuration block into a fixed
buffer and parsed those. The block's length is the device's own choice
(wTotalLength, a u16), so anything larger was silently cut: interfaces past
the cut did not exist as far as the host was concerned, while the
SET_CONFIGURATION that follows still configured the device for all of them.
A headset is 500-900 bytes, a UVC webcam 1-3 KB, a multifunction printer
600+.
Now allocated at the declared length, so the ceiling is the field's u16 —
the specification's number rather than one of ours. A block shorter than its
own 9-byte header is refused rather than trusted.
The bring-up line reports the declared length and the bytes actually read,
so a truncation can never again be invisible, and usb-large-descriptor
asserts they match with a backreference.
That case has an honest limit, recorded in its comment: QEMU cannot produce
a block over 512 bytes. The boot keyboard, mouse and stick are 34-44, and
the largest device available is usb-audio in multi-channel mode at 211 —
which is exactly why the suite never caught this, and why it cannot now
reproduce the original trigger. What it does catch is the class: any clamp
below the attached device's block fails it, verified by pinning the buffer
to 128 and watching "config block 211 bytes, read 128" turn the case red.
Suite 115 -> 116.
max_devices was 8, with the comment "QEMU presents a handful; a fuller
machine would grow this" — a number chosen against the test rig, waiting for
a real machine, which is the pattern docs/bounds-track-plan.md exists to
stop.
The driver already knew the true figure. It reads HCSPARAMS1.MaxSlots at
bring-up and writes it straight into op_config, so every slot the controller
offers has always been *enabled*; only the array tracking them was 8. QEMU's
xHCI reports 64, so seven eighths of the controller was live and invisible,
and the ninth device — a keyboard, mouse, webcam, headset, hub and two
sticks reach that without trying — disappeared on a hub-attached path that
logs nothing at all.
The array becomes a slice allocated from max_slots at bring-up. A controller
claiming zero slots cannot address anything, so that is now a dead
controller rather than an empty allocation failing mysteriously later. The
Device Context Base Address Array is a page, 511 usable entries, so it
already covered the 255-slot maximum.
The bring-up line reports both numbers, and usb-hid asserts they are equal
with a backreference rather than a magic number, so the test cannot drift
from the hardware. Pinning tracking back to 8 fails it: "64 slots,
tracking 8".
Suite 115/115.
The convention that tunables live in system/parameters.zig with their
reasoning attached predates this and got 2% compliance — 5 of 235. A
convention with no teeth is how a bare `const maximum_devices = 64` reached
an AMD desktop and cost it USB and storage. This is the same rule with a
gate behind it.
tools/check-bounds.py finds every bound-shaped declaration — a `maximum_*`
const with a literal value, or a type with a literal array length — and
requires the five-field block above it: what it counts, who decides its
size, what it protects, what happens at the limit, and how anyone finds out.
The at-limit vocabulary is closed: refuse, degrade, truncate, grow. There is
deliberately no way to spell "silent", no way to spell "drop", and nothing
meaning "allow", so the behaviours that did the damage cannot be written
down. Truncation is legal only carrying a marker the reader can see, which
is why klog_maximum_message qualifies and a USB descriptor cut at 512 bytes
does not.
An array length that names a declared bound is not itself a bound; only
literal lengths are flagged, which pushes ceilings toward having names.
The 273 that predate the rule are allowlisted so this lands without a
tree-wide sweep in front of it, and that list may only shrink: declaring a
bound means deleting its line, and the check fails on a stale entry too.
Nothing may be added.
Wired into `zig build test` and available alone as `zig build bounds`. Not
in the default build — it reads the whole tree, and a red bounds check
should not stop you booting a kernel.
Five are now declared rather than allowlisted. Writing them out is its own
argument: maximum_devices reads "protects: nothing — this is a sizing guess
about someone else's computer", and maximum_tasks now carries the fact that
it has been raised twice, each time by something that outgrew it.
Verified the gate refuses an undeclared bound, a declared one using
forbidden vocabulary, and an allowlist entry that has since been declared.
Suite 115/115.
Real-firmware finding: the per-file /system tree walk boots in seconds
under OVMF but stalls for MINUTES on real firmware — the cost is not
bytes (USB 3 moves the ~4 MB instantly) but firmware filesystem
OPERATIONS: ~30 opens, each an uncached directory-chain walk in an
unoptimized firmware FAT driver. This is why every real OS loader
(winload, GRUB) reads many files through its own filesystem code over
Block I/O rather than the firmware's file protocol.
The loader now reads boot/system.img — the bundled binaries packed into
ONE v2 initial_ramdisk (tools/pack-system-image.py, derived from the
same bundled list in the same build graph, so tree and capsule cannot
drift) — with a single open + sequential read, the one firmware file
I/O shape that is fast everywhere. The manifest (open each listed path
by name) and the tree walk remain as fallbacks, so a hand-assembled
stick without the capsule still boots. The running system is identical
in all three cases: the kernel receives the same in-RAM table.
The load phase now brackets itself with unconditional on-screen
breadcrumbs ('EFI: loading the system...' / '...starting the kernel'),
because this phase stalling behind a silent black screen — kernel
status is serial-only by design — already cost a real-hardware
debugging session.
Direction (settled with the user): this capsule becomes the BOOTSTRAP
capsule — kernel + init + the storage-bring-up set — once a
spawn-from-memory syscall lets init and the device manager load
everything else from the stick's real file tree at runtime through
danos's own storage stack: file-granular updates (rebuild one binary,
copy one file), the initramfs shape.
The EFI loader now ENUMERATES /system rather than opening files by name,
so the on-disk name must be exact, not merely case-insensitively
findable. make-fat-image.py stored 8.3-fitting lowercase names as bare
uppercase short entries ('init' -> INIT), garbling the ramdisk paths;
now any name whose case the 8.3 entry cannot reproduce gets a long-name
chain carrying the exact name.
device-manager's driver table stored names in 24 bytes — silently
truncating '/system/drivers/usb-xhci-bus' and failing the spawn; widen
to 64 (= abi.maximum_process_name). The usb-report harness regex learns
that restart lines name drivers by path.
Retire the build-time ramdisk packer and the packed initial-ramdisk.img.
make-fat-image.py now lays every user binary out at its FHS path on the
boot volume (system/services, system/drivers, system/tests), and the EFI
loader walks \system at boot, packing what it finds into an in-RAM v2
initial_ramdisk whose entry names are full FHS paths. init rides the
table like every other binary: its dedicated handoff fields are gone and
the kernel spawns PID 1 via the same lookup as everyone else
(process.spawnBundled).
system_spawn resolves names by exact path first, then unique basename,
and normalizes argv[0] to the stored path — so task names (and, next,
the tagged log ring's attribution) are honest binary paths everywhere.
initrd v2 rejects the old magic so a stale image fails loudly.
Groundwork for per-process logging (/var/log/<boot-stamp>/<binary-path>.log)
and the kernel-VFS /system mount.
The system now boots off a real FAT32 filesystem on a USB mass-storage device
instead of QEMU's synthesized VVFAT drive. A new in-repo image builder formats
that filesystem from the FHS boot tree, and both QEMU call sites (the run step
and the test harness) attach it as a usb-storage device on the xHCI bus, so
every boot exercises the full USB path OVMF -> BOOTX64.efi -> kernel.
- tools/make-fat-image.py: a Python 3 stdlib-only FAT32 formatter (mirrors
tools/make-initial-ramdisk.py — no external host dependencies). It lays down
the boot sector + BPB/EBPB32, FSInfo, backup boot sector, two FATs, and the
root/subdir/file cluster chains, emitting long-name entries where a name is
not 8.3. Packs the four boot inputs (EFI/BOOT/BOOTX64.efi, system/kernel,
system/services/init, boot/initial-ramdisk.img) into their boot paths. A
--verify subcommand re-checks the 0xAA55 signature, recomputes the cluster
count -> FAT32, and resolves EFI/BOOT/BOOTX64.efi, all with no dependencies.
- build.zig: a mk_fat step builds zig-out/danos-usb.img from the four boot
artifacts (so changing -Dtest-case rebuilds the image with that kernel), a
check-fat-image step runs --verify, and run-x86-64 boots the image on a
usb-storage device (if=none,id=bootusb + usb-storage,bus=xhci.0,bootindex=0),
keeping usb-kbd/usb-mouse on the same controller.
- test/qemu_test.py: the default boot config now boots off danos-usb.img on a
usb-storage device (xHCI + usb-kbd + usb-mouse + the boot stick). The seven
per-case qemu_extra blocks that added their own qemu-xhci/usb-kbd/usb-mouse
(or a VVFAT stick) collided on id=xhci and are removed — the default provides
the bus and the boot device. usb-storage and fat-mount now exercise the real
FAT32 boot image (usb-storage reads its 0x55AA boot sector; fat mounts it at
/mnt/usb). A build_case override lets a case reuse another's kernel, used by a
new usb-boot case: an explicit, named boot-from-USB regression guard.
Verified: zig build, zig build test, and zig build check-fat-image are green
(FAT32, 128992 clusters, BOOTX64.efi present); a broad sequential QEMU sweep
passes — smoke, init, vfs, input, device-manager, usb-report, usb-hid,
usb-storage, fat-mount, device-list, driver-restart, acpi-report, iommu,
orderly-shutdown, usb-boot, dma, msi, initial-ramdisk, args, process — proving
the boot switch holds across kernel tests, the full init tree, the USB stack,
the FAT mount, and orderly shutdown.
It was a bare awk snippet starting with `|`, meant to be pasted into a
pipeline. Turn it into an executable script that takes the log file as an
argument (tools/sort-lines-group-by-start.sh filename.log) and document its
behaviour and usage in a header comment.
Turn a keycode + modifiers into a character. The input module delivers HID
usage keycodes but nothing mapped them to characters; rather than hand-maintain
layout tables, vendor the X11 xkeyboard-config database and compile it to native
Zig at build time (no X11 runtime), the way make-initial-ramdisk.py packs the
ramdisk.
- tools/make-xkeyboard-config.py: `fetch` downloads the pinned xkeyboard-config
release (2.44, sha256-verified), resolves the include graph for the configured
layouts, and vendors only the reached symbols files + keysymdef.h + COPYING +
PROVENANCE into library/xkeyboard-config/vendor/. `generate` parses that
(keycodes via a HID->xkb-name table, symbols with include/augment/override and
per-key type, keysymdef for keysym->Unicode) and emits generated/layouts.zig
deterministically.
- library/xkeyboard-config/xkeyboard-config.zig: the API over the generated data
— map(layout, hid_usage, mods) -> { keysym, character }, byName, and the
level-selection semantics (the generated tables stay pure data). Host tests
assert US letters/digits with Shift/Caps, GB £ vs US # on Shift+3, and French
AZERTY q-where-US-has-a — the end-to-end proof of the parse->emit->lookup path.
- build.zig: `xkeyboard-config` + `layouts` modules, the test wired into
`zig build test`, and a `zig build gen-xkeyboard-config` convenience step.
- Layouts: us, gb, de, fr, es, dvorak. Scope (documented): group 1, no dead-key
composition, curated key types. Standalone library; wiring it into the input
path to fill KeyEvent.character is a documented follow-up.
zig build test green (incl. the new keymap tests); regeneration is byte-identical;
full QEMU suite 48/48 (unaffected — no kernel/runtime/service change).
Follow-up to the monorepo re-org. Suite 35/35 plus host tests green.
POSIX compatibility is now its own library, library/posix/ (unistd, stdio),
layered strictly over the runtime — it calls the runtime's IPC/heap, never
system calls directly. The runtime is now POSIX-free (the danos-native
application ABI). The VFS wire protocol is danos-native throughout
(Stat -> FileStatus, .stat -> .status, O_CREAT -> create); the POSIX layer
maps the POSIX spellings at the boundary. The coding standard's ABI-name
exception is scoped to one place: a file is allowed POSIX spellings only if it
lives under library/posix/ — everywhere else, danos naming with no exception.
Naming fixes, all mechanical:
- initrd -> initial-ramdisk: the source file, the module, the tool
(make-initial-ramdisk.py), the artifact (initial-ramdisk.img, including the
bootloader's load path), and the identifiers.
- system/kernel/device-service.zig -> devices-broker.zig: it is ring-0 kernel
code (the trusted device table + claim capability), not a ring-3 service. The
future user-space device *manager* (policy) will live in system/services/.
- Dropped the daemon `d` suffix: hpetd -> hpet, busd -> bus. A driver lives in
system/drivers/, so the folder already says what it is; encoding the role in
the name too is redundant. The coding standard drops that exception.
- system/devices/aml/interp.zig -> interpreter.zig (the type was already
Interpreter).
Ship more than one user binary: the bootloader now ferries an initrd bundle
(the VFS server + future drivers) alongside sbin/init, and the kernel unpacks
and spawns each program.
- src/user/proto/initrd.zig: the container format (Header{magic,count} +
Entry{name[32],offset,len} + blobs) with a validating Reader, shared by the
kernel and the packer.
- tools/mkinitrd.py: host-side packer (Python — trivial format, and sidesteps
the reworked Zig 0.16 std fs/args API). build.zig runs it on the built user
binaries via addSystemCommand and installs initrd.img to zig-out/bin + the
ESP root.
- BootInfo gains initrd_base/initrd_len; efi.zig loadInit refactored into a
shared loadFile, and loadInitrd ferries \initrd.img into surviving
LoaderData like init.
- main.zig startInitrdBinaries(): parse the image, spawn every entry (kernel-
spawns-all for now; init takes over via sys_spawn later).
- sbin/vfs.zig: a heartbeat stub (the real VFS server is M9), packed into the
initrd to prove the pipeline.
- New `initrd` test: parse + spawn + confirm the vfs stub reaches ring 3 and
heartbeats. Harness ships initrd.img on the ESP. Suite 30/30.