Commit Graph
13 Commits
Author SHA1 Message Date
luulu 6c6b9ba1b1 the walk's frame carries the class, so a listing can say what a record is
Closing the last hole in the substrate: `CUBE_OP_ENUM` and `CUBE_OP_RANGE`
returned `key | len | value`, so a kernel- or daemon-backed store's
`entries_flagged`/`range_flagged` could only answer from the trait's default —
"no class" for every record in the store. Both walks now return the flag scan's
frame, class included, and both stores answer for real.

`Command::List` and `Command::Range` print it, because the record has one: a
listing that reported only lengths is what made a caller open the store to find
out what it was looking at. That is also what keeps the kernel's listing and
userspace's comparable in the same words, which `verify-enum` and
`verify-range` are built on.

Three parsers had to follow, and each was found by a test rather than by
reading:

  * `CubeArgs`'s walk decoder in `cube-kernel` read the length at the wrong
    offset — caught by asserting the class of one record and none for its
    neighbours, so a decoder that read the wrong two bytes shows up as the
    wrong *record* rather than as a length that happens to be right;
  * the client's `listed` split a line on " [" and parsed the head, which is
    now `<coord> flags=0xNNNN`, so every listing came back EMPTY rather than
    failing — a parse that silently drops what it cannot read is the worst
    version of this;
  * the front-end gate looked for `root:799,0,0 [100B]`.

`verify-frontend` now asserts the class in a *listing* as well as in a point
read, which is the asymmetry the hole was made of: one store, two answers about
the same record.
2026-09-22 02:20:45 -04:00
luulu 8d3fab1dd1 the class crosses the socket, and the wrapper forwards both directions
`Reply` carries the record's class, so `CellGet` returns what `cell put flags=` wrote.
The JSON field is additive: a peer that does not send it reads as 0, which is what a
record written before the field existed means — the same answer rather than a wrong
one. `DaemonStore::get_flagged` takes it, so a remote store no longer answers "no
class" for a record that has one.

THE SEALING WRAPPER was the third instance of exactly that, and the front-end gate
found it: `SealedStore` forwarded `put_flagged` and then answered every read from the
trait's default. Worse, fixing `get_flagged` first was not enough — `entries_flagged`
and `range_flagged` were missing too, so a class survived a point read and vanished
from a listing. A wrapper has three read paths, not one, and the trait's default makes
each omission look like an answer.

So the trait now states the rule where it is implemented: a backend that stores masks
must override all three, the default's `flags: 0` is right only for a backend that
genuinely cannot carry one, and a *transport* that has no field for one produces the
same zero — so a backend of that second kind has to say so. `KernelStore` does: `get`
carries the class because `CUBE_OP_GET` fills it, and `entries`/`range` do not because
those frames have no field for it, which is why scanning a kernel store by class is
`CUBE_OP_FLAG_SCAN`'s job.

TWO GATE BUGS, both found while chasing that one. `verify-frontend` watched only
`cube-kernel/src` for changes, so an edit to `cube-store-seal` — exactly where the
class was being dropped — left the front-end binary stale and the gate testing the
previous behaviour: the gate whose own comment says "a stale front-end would test an
interface nobody has, which is how a green gate lies". It watches every crate now. And
it built its front-end-flavoured initramfs over the shared `initramfs.cpio.gz`, so a
gate was silently changing another gate's fixture; it builds to a path of its own.

`verify-flag-scan` now tells a boot that did not finish from a scan that answered
wrongly. That flake has now been seen three times, always inside a sweep and always
silent; two was a pattern, three is worth the distinction even before the cause.
2026-09-22 01:52:49 -04:00
luulu db50654641 KernelStore reads the class, and the gate reads a record where before it only scanned
`KernelStore::get_flagged` takes the mask out of the argument block, so the trait's
default no longer answers "no class" for a store that is holding one. That default
was the third instance of the same lie — MemStore had it, and a test caught it; this
one the transport could not express until the syscall learned to answer.

The guest's `get` prints what it got: `get x,y,z flags=0xNNNN N bytes: <value>`.
The flags sit before the count rather than after the value because the value is
variable-length and last, and the gates that parse this line read from `bytes: `
onward, so nothing that already read a value had to change.

`verify-flag-scan` now asserts it, on both paths: the class is read with the record
still in the log and again after the fold, and both must be the class it was written
with. A scan answers "which records are errors"; a read answers "what is *this*
record" — and they have to agree about the same bytes, which is what makes the class
a property of the record rather than of the query that found it.
2026-09-22 01:34:33 -04:00
luulu 52d44a628c cube: a caller can classify its own record — cell put <coord> flags=<hex> <value>
The write half of the substrate, for the userspace toolchain. The mask is the
*writer's* to state, because the writer is the one that knows, and it goes in
the record's own frame rather than in a header `cell put` exists to avoid.
Named `flags=<hex>` rather than a bare number so a value that happens to start
with a digit is never mistaken for a class, and re-rendered only when it is
non-zero so a command without one round-trips to the same text.

Every layer forwards it, including the ones that could have dropped it
quietly: the remote client (a store that dropped it would make classifying a
record a local-only ability), the kernel-backed store (the mask rides in
`cube_args` to the same kernel path that classifies the boot record), and the
sealing wrapper. `cube_args` grows from 80 to 88 bytes, which the kernel's
size-first dispatch still tells apart from the other three blocks.
2026-09-22 01:02:58 -04:00
luulu c77996329f userspace reads v3: one store, two layouts, and a fixture the kernel wrote
The kernel now folds into the addressed format, so userspace has to know it or a kernel-written
store is a store userspace cannot check — a fork of the format rather than a version of it. The raw
reader gained it: `parse` accepts v3, and `iter_records` walks the space table and the index,
handing out the same zero-copy records in the same (space, key) order as before.

The proof that this is one format and not two is a fixture: 700 bytes lifted out of a store the
kernel itself folded, committed at `crates/cube-store-raw/tests/fixtures/kernel-v3.img`, and an
integration test that reads it whole — ten records, 110 value bytes, three spaces in order, the
rehearsal's own values among them. A hand-built image cannot catch a bug that the builder and the
reader share, and that is exactly what the unit test in this crate did: it built its index with
8-byte lengths, so it never noticed that the kernel wrote 4-byte ones. The fixture did.

The digest's `bytes` figure was the layout's size, so the same store digested differently depending
on which format held it; it is now the records' logical size and the two agree. The gates that
compare a kernel image with a userspace one compare content and allow the layout to differ, saying
so where they do it.

Also here: the guest's `bench` verb and `verify-enum-cost.sh`, which is what turned all of this from
an argument into numbers — two stores forty times apart, four questions asked of both. It failed
when the walk was all there was, and it passes now.
2026-09-21 02:58:12 -04:00
luulu 347c949488 The kill switch rehearsed the way the machine would run it, and a show on /dev/fb0
Three things the rehearsal was getting wrong, all of them the kind that only
show up when the rehearsal is contained:

  * the gate stopped the LIVE store. Its paths were overridden to temporary
    files, but the unit it stopped was the real one, so a rehearsal took the
    machine's front-end down for the length of a run. KILL_HOLDER_UNIT names the
    unit to stop and the gate now asserts the real service did not move.
  * the password was a hardcoded default, so the kill switch was "armed" with a
    password that was in the transcript. Both rehearsal scripts mint a random
    password per run, and no hash file exists unless someone arms one.
  * the show: fireworks.rs draws straight onto the framebuffer in the guest that
    is destroying its own disk — no browser, no JavaScript engine, native Rust
    with mmap and a firework that keeps going as long as the destruction does.
    With no framebuffer (serial-only, or -display none) it quietly does nothing,
    because it is a witness to the destruction, not part of it.

install-box.sh is the two-pass box install this ran through: kernel first, then
the initramfs and the units, each pass verified before the next.
2026-09-20 23:16:10 -04:00
luulu 7faa3c88da cube(2) enumeration, end to end: the walk, two gates, and the batch that lied
The interface gap this closes was recorded, not papered over: `cube(2)` had no
ENUM/SPACES, so `list`, `spaces` and `range` through the front-end answered with
an honest error. Now the client walks the kernel's store — entries(), spaces(),
range() — and the front-end serves all three from it.

Two gates, because the two halves fail differently:

  * kernel/verify-enum.sh builds a 16 MiB device holding 3006 records over three
    spaces — one overwritten, one deleted, one empty value, 3000 bulk — boots the
    kernel, walks it through cube(2) and diffs that listing against the one
    userspace produces from the same device. Identical, line for line.

  * kernel/verify-frontend.sh asks through the socket: client -> front-end ->
    cube(2). Its store now holds 800 records in `root`, more than one 64 KiB
    batch, so `list` has to follow the cursor to reach root:799,0,0; `spaces`
    and `range` are asked for too, and the store the front-end leaves is
    digest-identical to the one those commands leave in userspace.

Building them found the bug worth keeping. A batch holds as many whole records
as fit, so it is FULL only when a record lands on the boundary — most batches
come back short. The client read "the buffer was not filled" as "the space is
exhausted" and so did the guest, and a 3006-record listing came back as 747
records looking exactly like a finished walk. The end of a walk is the cursor
alone. drain_batches() is that loop extracted so the rule is testable without a
kernel, with a test that fails on the old shape.

Three more seams the gates exposed and this fixes:

  * glibc's syscall() reports the kernel's -ENOENT as -1 with errno set, so the
    guest's end-of-walk test compared against -2 and never matched, and every
    error print said "Operation not permitted" whatever the errno was.
  * kernel/testbed/cube-client.c read the reply once, into 4 KiB. A listing is
    tens of KiB, so the client would have reported a truncated answer as the
    front-end's own; it reads to the newline now.
  * an EINVAL from the walk block on a kernel that knows only the coordinate
    block is now named for what it is, because "Invalid argument" from a call
    whose argument this code built is not something a caller can act on.

kernel/testbed/cube.c gained the `walk` verb, which is how the gate reads the
kernel's own listing out of the guest.
2026-09-20 23:16:02 -04:00
luulu bd5f3ccfb9 cube(2): read the errno from the libc, not from the syscall return value
glibc's syscall() returns -1 for *any* negative kernel return and stores the real
errno separately, so `-ret` on the error path is always 1 — EPERM. KernelStore::call
was computing `from_raw_os_error(-ret)`, so an absent record (the kernel's -ENOENT)
came back as "Operation not permitted".

That is why it hid this long: it only fires on the error path, and every early gate
read records that existed. It is also what the sealer's EPERM was hours ago — not
Morton codes, not a stray probe write; this. The fix reads std::io::Error::last_os_error()
when the syscall returns -1, which is what libc left in errno.

Proven by the encryption gate, which exercises exactly the missing-record path
(keys reads an absent keystore cell first): PASS, with a sealed envelope on the
device and no plaintext.
2026-09-19 14:32:30 -04:00
luulu 41862194e9 cube-seal: prefer is_empty() (clippy) 2026-09-19 05:27:31 -04:00
luulu 504a667cd9 The front-end serves a sealed store: writes seal, reads open, transparently
cube-frontend now wraps its kernel store in cube-store-seal's SealedStore. When
a key is available — `--key FILE`, or what systemd unsealed into
$CREDENTIALS_DIRECTORY — `cell put` seals and `cell get` opens, so the machine's
clients keep speaking the same language and the store holds ciphertext. With no
key, the wrapper passes envelopes through untouched, and `stats` reports
store=kernel+sealed=true/false so the state is visible rather than inferred.

The front-end's exact code path is now unit-tested without a kernel: the command
language over a sealed store round-trips — `cell put` leaves an envelope at rest,
`cell get` returns the plaintext — which is the claim the whole cutover depends
on, held by a test that runs in milliseconds instead of a boot.

SealedStore gained `inner()`, the raw ciphertext view, for that inspection and
for a process that deliberately wants what is at rest.
2026-09-19 05:27:06 -04:00
luulu efcfa90855 The kill, tested the way a VM makes possible: read with the key, and without it
verify-sealed-read.sh — three runs over one sealed store device, none of which
writes:

  run 1  the key is there   → every record opens, to the bytes it held before
  run 2  the key is gone    → not one record opens; the store is ciphertext
  run 3  a different key    → not one record opens either, which is the check
                              that makes run 2 mean something: "unreadable" must
                              not be the same as "my reader is broken"

The store is a slice of the machine's own records, sealed offline with a
throwaway key (`cube-image seal`), so the shapes and sizes are real and the key
protecting them exists only for the run. The guest does the reads through
`cube(2)` with cube-seal's new `--open-check` mode, which takes no key for the
"after the kill" case — a read-only pass that still reaches the store and
reports that nothing opens.

Two things the first run of this gate caught, both fixed:

  * the probe wrote. The sealer's put/get/delete probe was running in open-check
    mode too, and one put plus one delete appended to the log and rewrote the
    control block — so a run that was supposed to be read-only moved the store's
    digest. The read path was never the writer; the probe was. It is now gated to
    the sealing path, and the gate asserts the device is byte-identical across all
    three runs to keep it that way.
  * "no key" has to be an answer, not a refusal. With no key file the sealer used
    to exit before reaching the store; it now proceeds keyless and counts every
    sealed record as unopenable, which is the honest "after the kill" outcome.

Also: rehearse-erase.sh now measures reformatting in both its senses — mkfs with
and without its default discard, and `cube-image format` on a copy of this
machine's store (8192 of 8192 records survived a no-discard reformat; 200 of 200
of the store's own records survived its format call) — and testbed/store-survival.py
plus slice-store.py are the samplers and counters behind that.
2026-09-19 04:31:32 -04:00
luulu 15b9ba41ae selfdestruct: a guest that destroys its own disk when the kill password is presented
The contained version of the thing asked for: the drive is a 64 MiB file this
gate creates, the machine is a VM, and the destruction happens inside the guest
against its own virtual disk. The host's disk is never a candidate — the guest
gets exactly one disk, and the pristine copy is kept so the aftermath can be
compared.

Two runs, because one of them proves nothing on its own:

  run 1  kill password  → accepted, blkdiscard ok, 67108864 bytes overwritten,
                          powered off. The image afterwards: first16 all zeros,
                          record text gone, 65536 KiB materialised.
  run 2  wrong password → denied three times, disk byte-identical to pristine.

The password is not on the disk: the initramfs carries a salt and a hash, and
the guest compares a hash. (A real deployment should use the slow KDF the
store's recovery copy already uses; one SHA-256 is right for a rehearsal and
wrong for a secret a human typed.) It appears in the run-1 log only because it
was typed into that console.

Two bugs, both of which looked like "the guest ignored the password":

  * `-nographic` muxes stdio with the QEMU *monitor*, so a line written to
    stdin is eaten by the monitor and never reaches the guest's serial port.
    `-display none -serial stdio -monitor none` gives the guest the port, and
    input is sent after a delay and repeated, because bytes that arrive before
    the guest brings the port up are dropped.
  * the guest's initramfs had no /dev, so `/dev/vda` did not exist and the
    destroy step reported "No such file or directory" for a disk that was right
    there. It now mounts devtmpfs, sysfs and proc first.

Also in this commit: cube-seal takes coordinates in the language's own spelling
and parses them with the language's parser, because reading a record's key bytes
out of an image and calling them x, y, z is wrong — they are Morton codes, and
that mistake made the conversion rehearsal ask the kernel for coordinates that
never existed.
2026-09-19 03:26:28 -04:00
luulu d810957061 The demoted arrangement, and the boot chain the box uses
Two gates, both of which boot the thing rather than test its parts:

verify-frontend.sh serves the command language over a Unix socket from
/cube-frontend, which answers from the kernel's store through cube(2), and
compares what that leaves behind with what the same commands leave in
userspace. Building it found a real bug: the read took its length from the
syscall's *return* value, where the ABI reports it in the structure — so
every get returned an empty record and no error at all. Fixed in cube-kernel,
with the rule split into a function a test can hold (read_back).

verify-efi-boot.sh rehearses the boot loader half of a box boot, which
nothing else could: OVMF → GRUB in the ESP → ext4 /boot on the same NVMe disk
(GRUB reads disks through the firmware's block I/O, which is how a Surface
boots Linux at all) → kernel → initramfs → root. It boots the entry that
install-root.sh writes, so the installer is tested too, and it says why the
release comes from the linked kernel rather than from `make kernelrelease`
(a dirty tree appends '+', and that answer files modules where the running
kernel will never look — kernel-release.sh).

verify-box-preflight.sh is the read-only check that installing this on the
workhorse cannot cost it the way back: a different release, a module set that
covers what the running kernel loads (iwlwifi first — there is no wired NIC),
an entry in custom.cfg that the default does not mention, and a one-shot
grub-reboot to reach it once.

Also: the nvme rehearsal init now resolves root=UUID= as well as
root=PARTUUID= (the box uses UUID=), tested against a disk image before the
boot that depends on it; the installer refuses to overwrite a custom.cfg it
did not write; a test in cube-store that only passed when arithmetic wrapped
now computes the inclusive top instead; and every gate's QEMU gets
`timeout -k 5`, because thirteen orphans from earlier runs were still burning
cores hours later.
2026-09-19 01:31:36 -04:00