cubelinux: cube(2) walks the store — CUBE_OP_ENUM and CUBE_OP_SPACES

Enumeration was the one operation the interface did not have, and the one a
capability interface owes an explanation for: a listing is the opposite of
"knowing a coordinate is the authorisation to use it". So the walk is bounded
and explicit. A caller names the space, holds a cursor, and gets as many whole
records as fit in the buffer it offered, packed `key(24) | value_len(u32) | value`
in the store's own order. SPACES walks the distinct spaces that hold a record,
one per call; range is that walk with the region as a filter, applied by the
caller rather than by a second operation in the kernel.

Its own argument block, versioned by its own size: SYSCALL_DEFINE2 peeks `size`
and routes — 80 bytes is the coordinate block, 64 is this one. An interface that
cannot grow has to be replaced, and this one grows by being given a new block.

The end of a walk is the cursor alone. A batch holds as many whole records as
fit, so it is FULL only when a record lands on the boundary and most batches
come back short; a caller that reads "the buffer was not filled" as "the space
is exhausted" truncates its listing to the first batch and cannot tell. ENUM
answers a finished walk with no records and the cursor unmoved; SPACES answers
it with -ENOENT, because the space after the last one is not there. That rule is
in the uapi header because it is a contract detail, not an implementation one.

The kernel caches no merged index: each call walks the records in order, asks
the log — small, and the only thing that can override one — what the winner is,
and skips what the cursor has already covered. O(records) a call, tens of
milliseconds for the live store, which is worth more than a cache every write
would have to invalidate.
This commit is contained in:
surface-camera-build
2026-09-20 23:15:56 -04:00
parent ed5ff764ba
commit b9a7b8d8f3
3 changed files with 392 additions and 15 deletions
+143 -1
View File
@@ -15,8 +15,10 @@
* everything that touches the store's bytes is in the other.
*/
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/string.h>
#include <linux/syscalls.h>
#include <linux/uaccess.h>
#include <linux/cube.h>
@@ -33,11 +35,66 @@ ssize_t cubelinux_kernel_get(const __u8 *space, __u64 x, __u64 y, __u64 z,
int cubelinux_kernel_del(const __u8 *space, __u64 x, __u64 y, __u64 z);
int cubelinux_kernel_sync(void);
/*
* The walk (CUBE_OP_ENUM / CUBE_OP_SPACES). Its argument block travels through the same syscall
* but is a different struct, so these take the pieces rather than a pointer to one and the C
* side keeps owning everything that touches a userspace pointer.
*/
int cubelinux_kernel_enum(const __u8 *space, __u64 cursor, void *buf, size_t cap,
__u64 *out_len, __u64 *out_cursor);
int cubelinux_kernel_spaces(__u64 cursor, __u8 *space_out);
/* The store device path, resolved from the `cube_store=` boot parameter at boot. */
const char *cubelinux_store_device(void);
/*
* Where the store lives.
*
* A fixed `/dev/vda` was honest while a virtual machine was the only place this ran; the same
* driver now has to read the box's own device as well, and a constant cannot be both. So it is
* a boot parameter, and the box names its device on the kernel command line:
*
* cube_store=/dev/nvme0n1p2
*
* (`__setup` rather than `module_param_string`, deliberately. Built-in code registers module
* parameters under its *object's* name `MODULE_PARAM_PREFIX` is `KBUILD_MODNAME "."` when
* MODULE is not defined so a `module_param_string` here would answer to
* `cube_syscall.store_device`, named after this file rather than after the driver. That is a
* name nobody would guess and one more thing to get wrong at 3am. A `__setup` parameter is
* named exactly as written.)
*
* This lives in C because this kernel's Rust can express only *integer* module parameters
* rust/kernel/module_param.rs implements `ModuleParam` through `ParseInt` and nothing else
* and a path is not an integer. The Rust side asks for the string; it does not store it.
*
* The default keeps the boot gate's shape, so the QEMU rehearsal is unchanged.
*/
static char store_device_path[256] = "/dev/vda";
static int __init cube_store_setup(char *str)
{
strscpy(store_device_path, str, sizeof(store_device_path));
return 1;
}
__setup("cube_store=", cube_store_setup);
/* The Rust half reads the path through this; the storage stays here. */
const char *cubelinux_store_device(void)
{
return store_device_path;
}
/* No value may be larger than this in one call. A coordinate store is not a bulk-file path;
* a caller with more than this to store has more than one record to store. */
#define CUBE_MAX_VALUE (16u * 1024u * 1024u)
SYSCALL_DEFINE2(cube, unsigned int, op, void __user *, uargs)
/* The walk's buffer ceiling: the caller offers whatever it likes, up to this. */
#define CUBE_MAX_WALK (16u * 1024u * 1024u)
/*
* The coordinate operations put, get, del, sync which travel in `struct cube_args`.
*/
static long cube_args_op(unsigned int op, void __user *uargs)
{
struct cube_args args;
void *buf = NULL;
@@ -129,3 +186,88 @@ SYSCALL_DEFINE2(cube, unsigned int, op, void __user *, uargs)
kvfree(buf);
return ret;
}
/*
* The walk enum and spaces which travel in `struct cube_enum_args`: its own block so that the
* coordinate is not both an input and an output, and so the interface can grow by getting a new
* size rather than being replaced.
*
* A batch fills the caller's buffer and returns how much was used plus the cursor to pass next.
* A record that does not fit ends the batch; a record that cannot fit in any buffer the caller
* offered comes back as -ERANGE with `len` saying what it would need, exactly as a read does. So
* nobody guesses a size and nobody gets half a record.
*/
static long cube_enum_op(unsigned int op, void __user *uargs)
{
struct cube_enum_args e;
void *buf = NULL;
long ret = 0;
u64 out_len = 0, out_cursor = 0;
if (copy_from_user(&e, uargs, sizeof(e)))
return -EFAULT;
if (e.size != sizeof(struct cube_enum_args))
return -EINVAL;
if (op == CUBE_OP_SPACES) {
__u8 found[32];
ret = cubelinux_kernel_spaces(e.cursor, found);
if (ret < 0)
return ret;
memcpy(e.space, found, sizeof(found));
/* An index here, not a count of records: hand back the one after this space. */
e.cursor = e.cursor + 1;
e.len = 0;
if (copy_to_user(uargs, &e, sizeof(e)))
return -EFAULT;
return 0;
}
if (op != CUBE_OP_ENUM)
return -EINVAL;
if (e.len > CUBE_MAX_WALK)
return -E2BIG;
if (e.len > 0) {
buf = kvmalloc(e.len, GFP_KERNEL);
if (!buf)
return -ENOMEM;
}
ret = cubelinux_kernel_enum(e.space, e.cursor, buf, e.len, &out_len, &out_cursor);
if (ret == 0) {
if (out_len > 0 && copy_to_user((void __user *)e.value, buf, out_len))
ret = -EFAULT;
e.len = out_len;
e.cursor = out_cursor;
if (copy_to_user(uargs, &e, sizeof(e)))
ret = -EFAULT;
} else if (ret == -ERANGE) {
/* Nothing was written; `len` now says how much one record needs. */
e.len = out_len;
if (copy_to_user(uargs, &e, sizeof(e)))
ret = -EFAULT;
}
kvfree(buf);
return ret;
}
/*
* One syscall, two argument blocks. They share a prefix `size`, then `op` so the size the
* caller declares is what says which one arrived. That is the whole point of putting `size`
* first: an interface that cannot grow has to be replaced, and this one grows by being given a
* new block with a new size.
*/
SYSCALL_DEFINE2(cube, unsigned int, op, void __user *, uargs)
{
__u32 size;
if (copy_from_user(&size, uargs, sizeof(size)))
return -EFAULT;
if (size == sizeof(struct cube_args))
return cube_args_op(op, uargs);
if (size == sizeof(struct cube_enum_args))
return cube_enum_op(op, uargs);
return -EINVAL;
}
+241 -13
View File
@@ -75,13 +75,44 @@ const SPACE_ID_LEN: usize = 32;
const RAW_KEY_LEN: usize = 24;
const RECORD_FIXED: usize = SPACE_ID_LEN + RAW_KEY_LEN + 8;
/// The device to read. A module parameter is the obvious next step; for the boot gate one
/// fixed name is honest and has one less way to be wrong.
const STORE_DEVICE: &core::ffi::CStr = c_str!("/dev/vda");
// The device the store lives on, as the `cube_store=` boot parameter resolved it.
//
// This was a constant — *"for the boot gate one fixed name is honest and has one less way to
// be wrong"* — and that was true while a virtual machine was the only place this driver ran.
// It runs on the box now, the box's device is not `/dev/vda`, and a constant cannot be both.
// So the box names its device on the kernel command line:
//
// cube_store=/dev/nvme0n1p2
//
// The parameter is declared in C (`cube_syscall.c`) because this kernel's Rust can express only
// *integer* module parameters — `rust/kernel/module_param.rs` implements `ModuleParam` through
// `ParseInt` and nothing else — and a path is not an integer. It is a `__setup` parameter rather
// than a module parameter for the naming reason recorded in that file.
//
// (A `///` here would be an unused doc comment: it would attach to the `extern` block, which
// documents nothing.)
extern "C" {
/// Returns a pointer to a static, NUL-terminated buffer holding the path.
fn cubelinux_store_device() -> *const core::ffi::c_char;
}
/// Refuse to pull an unbounded device into memory. The gate's images are tiny; this is the
/// guard against a wrong device name turning a read into an allocation storm.
const MAX_BYTES: usize = 64 * 1024 * 1024;
/// The store's path, NUL-terminated, owned by the C side for the life of the kernel.
fn store_device() -> *const u8 {
// SAFETY: `cubelinux_store_device` returns a pointer to a static buffer that the module
// parameter filled during boot and that nothing writes afterwards, so it stays valid and
// NUL-terminated for as long as this runs.
unsafe { cubelinux_store_device().cast::<u8>() }
}
/// Refuse to pull an unbounded device into memory: the guard against a wrong device name
/// turning a read into an allocation storm. It is sized for a *store device*, not for an image.
///
/// `Control::layout` gives each of the two slots a quarter of the device, so a device must be at
/// least four times the image it holds — and a *sealed* image is larger than the plaintext one,
/// because every record gains an envelope. A sealed 18.8 MiB store therefore needs a ~76 MiB
/// device, which the original 64 MiB cap refused. 128 MiB leaves room for both to grow while
/// still being a bound: pointing this at a whole 119 GiB disk is still refused.
const MAX_BYTES: usize = 128 * 1024 * 1024;
/// The log's own magic, distinct from the image's so a reader that opens the wrong one
/// cannot mistake it for the other.
@@ -170,9 +201,10 @@ fn fnv1a64(bytes: &[u8], mut h: u64) -> u64 {
/// filesystem, say), that is the moment to reach for the block layer directly.
fn read_image(image: &mut KVVec<u8>) -> Result<()> {
// O_RDONLY is 0 in Linux; filp_open takes the raw flags word.
// SAFETY: STORE_DEVICE is a NUL-terminated C string literal, and filp_open either
// returns a valid `struct file *` or an error pointer, which is checked below.
let file = unsafe { bindings::filp_open(STORE_DEVICE.as_ptr().cast::<u8>(), 0, 0) };
// SAFETY: store_device() is a NUL-terminated C string that the module parameter filled at
// boot, and filp_open either returns a valid `struct file *` or an error pointer, which is
// checked below.
let file = unsafe { bindings::filp_open(store_device(), 0, 0) };
let file = kernel::error::from_err_ptr(file)?;
if file.is_null() {
return Err(EINVAL);
@@ -1017,9 +1049,9 @@ fn append(
return Err(ENOSPC);
}
// SAFETY: STORE_DEVICE is a NUL-terminated literal; filp_open returns a valid file or an
// error pointer, which is checked. O_RDWR is 2.
let file = unsafe { bindings::filp_open(STORE_DEVICE.as_ptr().cast::<u8>(), 2, 0) };
// SAFETY: store_device() is a NUL-terminated C string filled at boot; filp_open returns a
// valid file or an error pointer, which is checked. O_RDWR is 2.
let file = unsafe { bindings::filp_open(store_device(), 2, 0) };
let file = kernel::error::from_err_ptr(file)?;
if file.is_null() {
return Err(EINVAL);
@@ -1134,7 +1166,7 @@ fn checkpoint(ctl: &Control, merged: &mut Merged) -> Result<u64, Error> {
}
// SAFETY: as in `append`.
let file = unsafe { bindings::filp_open(STORE_DEVICE.as_ptr().cast::<u8>(), 2, 0) };
let file = unsafe { bindings::filp_open(store_device(), 2, 0) };
let file = kernel::error::from_err_ptr(file)?;
if file.is_null() {
return Err(EINVAL);
@@ -1295,6 +1327,202 @@ pub unsafe extern "C" fn cubelinux_kernel_get(
}
}
/// Pack one record the way the walk's uapi names it: `key(24) | value_len(u32, LE) | value`.
///
/// The space is not in the frame because the caller named it, and the order is the store's own
/// (space, then key), so a listing taken through the kernel and one taken in userspace are
/// byte-for-byte comparable — which is how this is tested.
fn pack_record(key: &[u8], value: &[u8], out: &mut [u8], at: usize) -> Option<usize> {
let need = RAW_KEY_LEN + 4 + value.len();
if at + need > out.len() {
return None;
}
out[at..at + RAW_KEY_LEN].copy_from_slice(&key[..RAW_KEY_LEN]);
out[at + RAW_KEY_LEN..at + RAW_KEY_LEN + 4]
.copy_from_slice(&(value.len() as u32).to_le_bytes());
out[at + RAW_KEY_LEN + 4..at + need].copy_from_slice(value);
Some(need)
}
/// `CUBE_OP_ENUM`: walk the records of one space into the caller's buffer, `cursor` records in.
///
/// The cursor is a COUNT OF RECORDS ALREADY RETURNED, not a position in the image. It is opaque to
/// the caller (pass back what you were given) and it survives an append, because the contract is
/// "the records I had not yet seen" rather than a snapshot. A record can be seen twice if the
/// image is rewritten underneath a walk; a caller that needs a snapshot takes one.
///
/// The walk is O(records) per call, deliberately. The alternative is a merged index held in the
/// kernel and invalidated by every write — more to be wrong about than a listing is worth, and the
/// live store's 69,636 records make a page of listing tens of milliseconds.
///
/// `out_len` receives the bytes written, or with -ERANGE the size the first record that did not fit
/// would need — exactly as a read reports the size it wants.
///
/// # Safety
/// `space` must point to 32 readable bytes; `buf` must hold `cap` writable bytes; `out_len` and
/// `out_cursor` must each point to a writable `u64`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cubelinux_kernel_enum(
space: *const u8,
cursor: u64,
buf: *mut u8,
cap: usize,
out_len: *mut u64,
out_cursor: *mut u64,
) -> i32 {
let mut wanted = [0u8; SPACE_ID_LEN];
// SAFETY: the caller guarantees 32 readable bytes at `space`.
unsafe { core::ptr::copy_nonoverlapping(space, wanted.as_mut_ptr(), SPACE_ID_LEN) };
let (device, layout) = match device_and_layout() {
Ok(pair) => pair,
Err(e) => return -(e.to_errno() as i32),
};
let live = &device[layout.image_off..];
let header = match parse_header(live) {
Ok(h) => h,
Err(what) => {
pr_err!("cubelinux: {}\n", what);
return -22; // -EINVAL
}
};
let window = log_window(&device, &layout);
let end = core::cmp::min(
header.image_bytes.unwrap_or(live.len() as u64) as usize,
live.len(),
);
let (mut merged, _applied) = match build_merged(&live[..end], window, &header) {
Ok(m) => m,
Err(what) => {
pr_err!("cubelinux: cannot build the store: {}\n", what);
return -22;
}
};
merged.sort_entries();
let entries = merged.entries.as_slice();
// SAFETY: the shim guarantees `cap` writable bytes at `buf`.
let out = unsafe { core::slice::from_raw_parts_mut(buf, cap) };
let mut written = 0usize;
let mut returned: u64 = 0;
let mut seen: u64 = 0;
let mut first_too_big: usize = 0;
let mut i = 0usize;
while i < entries.len() {
let head = &entries[i];
// The winner of a coordinate is the LAST of its group: the newest write, or a removal.
let mut last = i;
while last + 1 < entries.len()
&& entries[last + 1].space == head.space
&& entries[last + 1].key == head.key
{
last += 1;
}
let winner = &entries[last];
i = last + 1;
if winner.deleted || winner.space != wanted {
continue;
}
seen += 1;
if seen <= cursor {
continue;
}
match pack_record(&winner.key, merged.value(winner), out, written) {
Some(n) => {
written += n;
returned += 1;
}
None => {
first_too_big = RAW_KEY_LEN + 4 + winner.value_len as usize;
break;
}
}
}
// SAFETY: both out-pointers are writable under this function's contract.
unsafe {
if first_too_big > 0 && written == 0 {
// Not one whole record fits. Say how much it needs, the way a read does.
*out_len = first_too_big as u64;
*out_cursor = cursor;
return -34; // -ERANGE
}
*out_len = written as u64;
*out_cursor = cursor + returned;
}
0
}
/// `CUBE_OP_SPACES`: the `cursor`-th distinct space that holds a record, or -ENOENT at the end.
///
/// An index rather than a count of records: a caller that wants a space's records walks it with
/// `CUBE_OP_ENUM` once it has learned the name. Entries are already in (space, key) order, so the
/// distinct spaces come out sorted.
///
/// # Safety
/// `space_out` must point to 32 writable bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn cubelinux_kernel_spaces(cursor: u64, space_out: *mut u8) -> i32 {
let (device, layout) = match device_and_layout() {
Ok(pair) => pair,
Err(e) => return -(e.to_errno() as i32),
};
let live = &device[layout.image_off..];
let header = match parse_header(live) {
Ok(h) => h,
Err(what) => {
pr_err!("cubelinux: {}\n", what);
return -22;
}
};
let window = log_window(&device, &layout);
let end = core::cmp::min(
header.image_bytes.unwrap_or(live.len() as u64) as usize,
live.len(),
);
let (mut merged, _applied) = match build_merged(&live[..end], window, &header) {
Ok(m) => m,
Err(what) => {
pr_err!("cubelinux: cannot build the store: {}\n", what);
return -22;
}
};
merged.sort_entries();
let entries = merged.entries.as_slice();
let mut index: u64 = 0;
let mut previous: Option<[u8; SPACE_ID_LEN]> = None;
let mut i = 0usize;
while i < entries.len() {
let head = &entries[i];
let mut last = i;
while last + 1 < entries.len()
&& entries[last + 1].space == head.space
&& entries[last + 1].key == head.key
{
last += 1;
}
let winner = &entries[last];
i = last + 1;
if winner.deleted {
continue;
}
if previous != Some(winner.space) {
if index == cursor {
// SAFETY: the caller guarantees 32 writable bytes at `space_out`.
unsafe {
core::ptr::copy_nonoverlapping(winner.space.as_ptr(), space_out, SPACE_ID_LEN);
}
return 0;
}
index += 1;
previous = Some(winner.space);
}
}
-2 // -ENOENT: no such space; the walk is finished
}
/// `CUBE_OP_DEL`: remove the record at a coordinate.
///
/// A removal is a log entry, not an erasure: nothing in an append-only store is rewritten in
+8 -1
View File
@@ -51,13 +51,20 @@ struct cube_args {
* order space first, then key which is the order a checkpoint writes them and the order the
* userspace store returns them, so a kernel listing and a userspace listing can be compared
* directly. The space is not repeated per record: the caller named it.
*
* A walk ends when the cursor stops moving, and that is the only end signal: a batch holds as
* many whole records as fit, so most batches come back short, and reading a short batch as the
* end truncates a listing to its first batch. CUBE_OP_ENUM answers a finished walk with no
* records and the cursor unchanged; CUBE_OP_SPACES answers it with -ENOENT, because asking for
* the space after the last one is asking for a space that is not there. -ERANGE keeps its usual
* meaning: not one whole record fits, and `len` says how much one needs.
*/
struct cube_enum_args {
__u32 size; /* sizeof(struct cube_enum_args) as the caller built it */
__u32 op; /* CUBE_OP_ENUM or CUBE_OP_SPACES */
__u8 space[32]; /* in: the space to walk; out: the space found (CUBE_OP_SPACES) */
__u64 cursor; /* in: 0 to start, or what the last call returned;
* out: what to pass next
* out: what to pass next see the end-of-walk rule above
*/
__u64 value; /* user pointer: where to put the records */
__u64 len; /* in: the buffer's capacity;