Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Peios

Registry sources

Single-page view · as markdown

Registry sources overview

Peios / Developing for Peios / Registry sources

A registry source is a storage backend for the LCS registry. It is the other half of the registry story: where a client opens keys and reads values, a source is what actually holds those keys and values and answers the kernel when it needs them. If you are implementing a place for registry data to live — a file-backed store, a database adapter, an in-memory provider for tests — you are writing a source, and librsi is the library for it.

This is a different library from libpeios, for a different audience. libpeios is the registry client; librsi is the registry source. They share the SDK's conventions and substrate, but you link librsi (-lrsi, <rsi.h>) to write a source.

How it works #

A source talks to the kernel over the RSI protocol — the Registry Source Interface — on a single file descriptor:

flowchart LR
    client[Registry client<br/>libpeios] -->|syscalls| kernel[LCS in the kernel]
    kernel <-->|RSI framed protocol<br/>over the source fd| source[Your source<br/>librsi]

The flow is:

  1. Register. Your process declares which hives (registry subtrees) it backs and registers with the kernel, receiving a source fd. This needs SeTcbPrivilege.
  2. Serve. The kernel sends your source requests — "look up this child", "store this value", "begin this transaction" — as framed messages you read(2) from the source fd. You decode each one, do the work against your storage, and write(2) back a framed response.
  3. Repeat until the source fd reaches EOF (the source is closing).

When a client reads or writes a key in one of your hives, the kernel turns that into RSI requests to you. Your source is the source of truth; the kernel mediates, enforces security, and resolves layer precedence on top of what you report.

The serve loop #

Every source is, at heart, this loop:

for (;;) {
    ssize_t n = rsi_read_request(src_fd, buf, sizeof buf);   /* read a frame */
    if (n == 0) break;                                       /* EOF: closing */
    if (n < 0) { /* errno */ break; }

    struct rsi_request req;
    rsi_parse_request(buf, n, &req);                         /* split header/payload */

    switch (req.op_code) {                                   /* dispatch */
        case RSI_LOOKUP:     /* decode, resolve, rsi_respond_lookup */    break;
        case RSI_SET_VALUE:  /* decode, store,   rsi_respond_status */    break;
        /* … one case per op you support … */
    }
}

The three headers map onto the three steps:

  • <rsi/source.h> — registering and getting the source fd.
  • <rsi/request.h> — reading and decoding requests.
  • <rsi/response.h> — building and sending responses.

What the kernel handles, and what you handle #

The division of labour matters, because it keeps a source simple:

  • The kernel handles security (access checks against key SDs), layer precedence resolution, transaction coordination across sources, and the client-facing syscall surface. It also assigns the global sequence numbers.
  • Your source handles durable storage: it stores the name→GUID entries, the key metadata records, and the layered values, and it reports them back faithfully when asked. It honours transaction boundaries (buffer, then commit or abort) and compare-and-swap guards on writes.

You do not implement precedence, access control, or the client protocol — you store data and answer questions about it. That's why a source's serve loop is mostly a dispatch table over storage operations.

Requests come in families #

The request reference groups the operations, and it helps to hold the shape in mind:

  • Path/entry ops — the name→GUID hierarchy (LOOKUP, CREATE_ENTRY, HIDE_ENTRY, DELETE_ENTRY, ENUM_CHILDREN).
  • Key ops — key metadata records (CREATE_KEY, READ_KEY, DROP_KEY, WRITE_KEY).
  • Value ops — the typed values on a key (QUERY_VALUES, SET_VALUE, DELETE_VALUE_ENTRY, SET_BLANKET_TOMBSTONE).
  • Transaction ops — atomic grouping (BEGIN/COMMIT/ABORT_TRANSACTION).
  • Layer ops — DELETE_LAYER, FLUSH.

Most reply with a simple status; five carry data back on success.

Where to go in this section #

  • Registering a source — declare your hives and get the source fd.
  • Serving requests — the read/parse/dispatch/decode loop in full.
  • Building responses — status-only and payload-bearing replies.

Registering a source

Peios / Developing for Peios / Registry sources

Every source starts by registering. You tell the kernel which hives your process backs, and it hands you a source fd to serve on. The full detail is in rsi/source.h; this guide walks the decisions.

Declare your hives #

A hive is a registry subtree with its own root key. Fill in one struct rsi_hive per hive your source backs:

struct rsi_hive hive = {
    .name     = "MyStore",
    .name_len = 7,
    .flags    = 0,                    /* global hive */
    .root_guid = { /* 16-byte GUID of the hive root key */ },
    /* scope_guid left zero for a global hive */
};

The two decisions per hive:

  • Global or private? A global hive (flags = 0, scope_guid zero) is visible system-wide. A private hive (flags = RSI_HIVE_PRIVATE, scope_guid non-zero) is scoped — only tokens carrying that scope GUID in their LCS credentials can resolve it. Use a private hive for per-application or per-tenant state that shouldn't be system-visible.
  • The root GUID. Every hive is anchored at a root key identified by root_guid. This is the GUID paths in the hive resolve from, and it must match the root your storage actually holds.

Register #

Hand the kernel your hive array and the highest sequence number you've already persisted:

int src = rsi_register(&hive, 1, /*max_sequence=*/0);
if (src < 0) {
    perror("rsi_register");   /* EPERM if you lack SeTcbPrivilege */
    return -1;
}
/* `src` is the source fd — serve the RSI protocol on it. */

Two things to get right:

  • SeTcbPrivilege is required. Registration is a trusted operation; without the privilege you get EPERM. Sources run as trusted system components.
  • max_sequence is your durability contract. It is the highest sequence number this source has already persisted. The kernel resumes its global sequence counter past it, so it never hands out a number you've used. A brand-new source with no stored state passes 0; a source restarting with durable data must scan that data for the highest sequence it ever wrote and pass that. Getting this wrong risks sequence reuse, so make it the first thing your restart path computes.

You can register several hives in one call by passing an array and a count greater than one; the kernel enforces its configured MaxHivesPerSource limit (ENOSPC if you exceed it).

After registration #

The returned fd is your serve endpoint — you read(2) requests and write(2) responses on it directly (via the request and response helpers). Closing it deregisters the source and signals EOF to the kernel. Keep it open for the life of the source, and move on to the serve loop.

Next #

  • Serving requests — the loop that runs on the source fd.
  • rsi/source.h reference — every field and error.

Serving requests

Peios / Developing for Peios / Registry sources

With a source fd in hand, a source spends its life in one loop: read a request, decode it, do the work, reply. This guide builds that loop. The exhaustive per-op detail is in rsi/request.h and rsi/response.h.

The loop skeleton #

unsigned char buf[64 * 1024];   /* size generously — a short buffer is EMSGSIZE */

for (;;) {
    ssize_t n = rsi_read_request(src_fd, buf, sizeof buf);
    if (n == 0) break;                       /* EOF — the source is closing */
    if (n < 0) { perror("read_request"); break; }

    struct rsi_request req;
    if (rsi_parse_request(buf, n, &req) != 0) continue;   /* EBADMSG — skip */

    switch (req.op_code) {
        case RSI_LOOKUP:    handle_lookup(src_fd, &req);    break;
        case RSI_SET_VALUE: handle_set_value(src_fd, &req); break;
        /* … a case per op you support … */
        default:
            /* Unknown or unsupported op: reply with a non-OK status
             * (RSI_TXN_NOT_SUPPORTED for transaction ops you don't implement). */
            rsi_respond_status(src_fd, &req, RSI_INVALID);
            break;
    }
}

Three details in that skeleton matter:

  • rsi_read_request blocks until a request is queued, and returns 0 at EOF — that's your exit. Size buf generously; a frame larger than buf returns -1/EMSGSIZE.
  • rsi_parse_request gives you req.op_code to dispatch on, plus req.request_id (which every reply must echo — the helpers do this for you) and req.txn_id (nonzero when the request is inside a transaction).
  • Always reply. Every request expects exactly one response. If you can't handle an op, reply with a non-OK status rather than dropping it.

Decoding a request #

Inside a handler, decode the payload with the matching rsi_request_* parser. For a LOOKUP:

void handle_lookup(int fd, const struct rsi_request *req)
{
    struct rsi_lookup q;
    if (rsi_request_lookup(req, &q) != 0) {          /* EBADMSG */
        rsi_respond_status(fd, req, RSI_INVALID);
        return;
    }

    /* q.parent_guid (by value), q.child_name / q.child_name_len (borrowed).
       Resolve the child in your storage across its layers … */

    /* … then reply with the resolved path entries + metadata: */
    rsi_respond_lookup(fd, req, entries, entry_count, metadata, metadata_count);
}

And for a SET_VALUE, a status-only op:

void handle_set_value(int fd, const struct rsi_request *req)
{
    struct rsi_set_value v;
    if (rsi_request_set_value(req, &v) != 0) {
        rsi_respond_status(fd, req, RSI_INVALID);
        return;
    }

    /* Honour the CAS guard before storing. */
    if (v.expected_sequence != 0 && current_seq(&v) != v.expected_sequence) {
        rsi_respond_status(fd, req, RSI_CAS_FAILED);
        return;
    }
    store_value(&v);                                  /* your storage */
    rsi_respond_status(fd, req, RSI_OK);
}

The borrow rule #

Every decoded name and data field — q.child_name, v.value_name, v.data, and the rest — is a (ptr, len) pair that points into buf. Those pointers are valid only until the next rsi_read_request reuses the buffer. So:

  • Consume them within the handler (store the bytes, resolve the name) before the loop comes around again, or
  • Copy out anything you need to keep. Never stash a raw borrowed pointer across iterations.

GUIDs and scalars in the decoded struct are copied by value, so those are always safe to keep — it's only the borrowed (ptr, len) fields that have the lifetime.

Transactions #

When the kernel sends BEGIN_TRANSACTION, buffer subsequent writes tagged with that transaction_id instead of applying them; req.txn_id on each later request tells you which transaction it belongs to (0 = none). On COMMIT_TRANSACTION apply the buffered set atomically; on ABORT_TRANSACTION discard it. All three are status-only replies. The kernel coordinates transaction boundaries — your job is to buffer, then commit or discard on command.

Next #

  • Building responses — choosing and filling the right reply.
  • rsi/request.h reference — every op's decoder and struct.

Building responses

Peios / Developing for Peios / Registry sources

Every request gets exactly one response. Choosing the right one is simple once you know the rule; filling it in is a matter of handing librsi flat arrays and letting it encode the frame. The full contract is in rsi/response.h; this guide is the working version.

The rule #

On failure, always rsi_respond_status. On success, rsi_respond_status too — unless the op is one of the five that carry a payload.

Most operations (SET_VALUE, CREATE_KEY, the transaction ops, FLUSH, …) are status-only in both cases. And any op reports a non-OK outcome with rsi_respond_status, whatever it is:

/* Success for a status-only op: */
rsi_respond_status(fd, req, RSI_OK);

/* Any op reporting a problem: */
rsi_respond_status(fd, req, RSI_NOT_FOUND);   /* e.g. a missing key */
rsi_respond_status(fd, req, RSI_CAS_FAILED);  /* a failed CAS */

You never build the frame or echo the request id yourself — the helper reads what it needs from req and writes the framed reply to fd.

The five payload-bearing ops #

Exactly five ops return data on success, each with its own helper:

OpHelperYou supply
LOOKUPrsi_respond_lookuppath entries + the referenced keys' metadata
ENUM_CHILDRENrsi_respond_enum_childrenchildren (name + path entries) + metadata
READ_KEYrsi_respond_read_keyone key's non-layered metadata
QUERY_VALUESrsi_respond_query_valuesvalue entries + blanket tombstones
DELETE_LAYERrsi_respond_delete_layerthe orphaned keys' GUIDs

You pass the result as flat arrays; librsi validates and heap-encodes the wire frame. A QUERY_VALUES reply, for example:

struct rsi_value_entry values[] = {
    { .value_name = "Timeout", .value_name_len = 7,
      .layer_name = "base",    .layer_name_len = 4,
      .value_type = REG_DWORD, .data = &timeout, .data_len = 4,
      .sequence = 42 },
    /* … one per effective value you hold … */
};

rsi_respond_query_values(fd, req, values, 1, /*blankets=*/NULL, 0);

You report what you store, per layer; the kernel resolves precedence across the layers you return. (Note the (NULL, 0) for the empty blanket array — a NULL pointer is allowed only when its count is zero.)

The validation contract #

The helpers check their inputs before encoding and return -1/EINVAL if you break the contract — better a caught programming error than a malformed frame on the wire. The rules that apply across all of them:

  • (ptr, len) pairs: a pointer may be NULL only when its length/count is zero.
  • Booleans (volatile_key, symlink, target types) are strictly 0 or 1.
  • Hidden path targets (RSI_PATH_TARGET_HIDDEN) carry an all-zero target_guid.
  • LOOKUP / ENUM_CHILDREN metadata must exactly cover the GUID targets in your path entries — every referenced key present, none missing, no duplicates, nothing unreferenced.
  • DELETE_LAYER orphan GUIDs are nonzero and unique.

Beyond EINVAL, a helper can also fail with ENOMEM or EOVERFLOW (building the frame), EIO (a short write), or the raw write(2) errno — treat those as you would any I/O failure on the source fd.

Putting it together #

A source's handler for a payload-bearing op is: decode the request, gather the result from storage into the flat arrays the helper wants, call the helper. For a status-only op: decode, do the work, rsi_respond_status. On any error along the way — a decode failure, a missing key, an I/O problem — rsi_respond_status with the appropriate non-OK RSI_* code. That uniformity is what keeps a source's dispatch table readable no matter how many ops it supports.

Next #

  • rsi/response.h reference — every helper, struct, and error in full.
  • Serving requests — the loop these replies live in.

Peios Learn — documentation for the Peios project.

Built with Trail.