nbos

nbos

https://git.tonybtw.com/nbos.git git://git.tonybtw.com/nbos.git

Initial commit.

Commit
4f8061673b20331eb495d9275cbf397a99937d6a
Author
tonybanters <tonybanters@gmail.com>
Date
2026-05-09 01:24:23

Diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..10e7215
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+*.o
+.clangd
+.cache/
+build/
+notes/
+compile_commands.json
+nb
+pkgs/all.h
+pkgs/all.c
+pkgs/all.h.tmp
+pkgs/all.c.tmp
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..dcee192
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,60 @@
+# Nb Makefile. C23, strict warnings, sanitizer-ready.
+
+CC      ?= cc
+CFLAGS  ?= -std=c23 -O2 -g \
+           -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
+           -Wstrict-prototypes -Wmissing-prototypes \
+           -Wno-unused-parameter \
+           -fno-strict-aliasing
+LDFLAGS ?=
+
+ifdef SANITIZE
+    CFLAGS  += -fsanitize=address,undefined -fno-omit-frame-pointer
+    LDFLAGS += -fsanitize=address,undefined
+endif
+
+INCLUDES = -Iinclude -Isrc
+
+SRC_FILES = \
+    src/main.c \
+    src/arena.c \
+    src/error.c \
+    src/hash.c \
+    src/store.c \
+    src/resolve.c \
+    src/validate.c \
+    src/fetch.c \
+    src/sandbox.c \
+    src/build.c \
+    src/run.c \
+    src/realize.c \
+    src/activate.c
+
+PKG_FILES = $(shell find pkgs -name '*.c')
+
+CONFIG_FILE ?= etc-example/config.c
+
+OBJ_FILES = $(SRC_FILES:.c=.o) $(PKG_FILES:.c=.o)
+
+.PHONY: all clean registry switch
+
+all: nb
+
+nb: $(OBJ_FILES) $(CONFIG_FILE:.c=.o) pkgs/all.o
+	$(CC) $(LDFLAGS) -o $@ $^
+
+%.o: %.c
+	$(CC) $(CFLAGS) $(INCLUDES) -c -o $@ $<
+
+registry:
+	./tools/gen-registry.sh > pkgs/all.h.tmp
+	mv pkgs/all.h.tmp pkgs/all.h
+	./tools/gen-registry.sh --c > pkgs/all.c.tmp
+	mv pkgs/all.c.tmp pkgs/all.c
+
+clean:
+	find . -name '*.o' -delete
+	rm -f nb
+
+switch: nb
+	./nb switch
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3d06051
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# nb
+
+A from-source declarative Linux distribution written in disciplined C23.
+
+## Build
+
+    make registry
+    make
+    sudo ./nb validate
+    sudo ./nb switch
+
+## design choices (subject to change)
+
+- **No daemon.** Single-user model. `nb switch` runs as root, does its
+  work, exits. No persistent process, no IPC, no socket protocol.
+- **No custom config DSL for v0.1.** The user's config is plain C against
+  the schema. A translator to a friendlier syntax can be added later
+  without changing the core.
+- **Content-addressed store.** `/nb/store/<hash>-<name>-<version>/`.
+  The hash includes the pkg's deps' store paths transitively, so any
+  change in any dep reshuffles every dependent.
+- **Generations are immutable; rollback is symlink swap.** The previous
+  generation always remains bootable until garbage-collected.
diff --git a/etc-example/config.c b/etc-example/config.c
new file mode 100644
index 0000000..fae9322
--- /dev/null
+++ b/etc-example/config.c
@@ -0,0 +1,42 @@
+/* /etc/nb/config.c */
+#include "../include/nbos.h"
+#include "../pkgs/core/glibc/glibc.h"
+#include "../pkgs/core/linux/linux.h"
+#include "../pkgs/editor/neovim/neovim.h"
+#include "../pkgs/wm/oxwm/oxwm.h"
+#include "../pkgs/shell/dash/dash.h"
+#include "../pkgs/browser/firefox/firefox.h"
+
+static const pkg *const my_pkgs[] = {
+    &pkgs_glibc,
+    &pkgs_linux,
+    &pkgs_neovim,
+    &pkgs_oxwm,
+    &pkgs_firefox,
+};
+
+static const char *const sshd_after[]  = { "network" };
+static const service my_services[] = {
+    { .name = "sshd", .enabled = true,
+      .after = { .data = sshd_after, .len = 1 } },
+};
+
+static const char *const tony_groups[] = { "wheel", "video", "audio" };
+static const user my_users[] = {
+    { .name   = "tony",
+      .shell  = &pkgs_dash,
+      .home   = "/home/tony",
+      .groups = { .data = tony_groups, .len = 3 } },
+};
+
+const system_cfg CFG = SYSTEM_CFG_INIT(
+    .hostname = "nbos-btw",
+    .boot = {
+        .bootloader    = "limine",
+        .kernel        = &pkgs_linux,
+        .kernel_params = "quiet loglevel=3",
+    },
+    .pkgs     = { .data = my_pkgs,     .len = 5 },
+    .services = { .data = my_services, .len = 1 },
+    .users    = { .data = my_users,    .len = 1 },
+);
diff --git a/include/nbos.h b/include/nbos.h
new file mode 100644
index 0000000..56d61af
--- /dev/null
+++ b/include/nbos.h
@@ -0,0 +1,85 @@
+#ifndef NBOS_H
+#define NBOS_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#define NBOS_SCHEMA_VERSION 1
+
+typedef struct {
+    const char *const *data;
+    size_t             len;
+} strs;
+
+typedef struct pkg pkg;
+
+typedef struct {
+    const pkg *const *data;
+    size_t            len;
+} pkg_refs;
+
+typedef enum : uint8_t {
+    BUILD_AUTOTOOLS = 1,
+    BUILD_CMAKE     = 2,
+    BUILD_MESON     = 3,
+    BUILD_MAKE      = 4,
+    BUILD_CARGO     = 5,
+    BUILD_GO        = 6,
+    BUILD_ZIG       = 7,
+    BUILD_SHELL     = 99,
+} build_system;
+
+struct pkg {
+    const char  *name;
+    const char  *version;
+    const char  *src;
+    const char  *sha256;
+    pkg_refs     deps;
+    const char  *build_flags;
+    build_system build_sys;
+};
+
+typedef struct {
+    const char *name;
+    bool        enabled;
+    strs        after;
+} service;
+
+typedef struct {
+    const service *data;
+    size_t         len;
+} services;
+
+typedef struct {
+    const char *name;
+    const pkg  *shell;
+    const char *home;
+    strs        groups;
+} user;
+
+typedef struct {
+    const user *data;
+    size_t      len;
+} users;
+
+typedef struct {
+    const char *bootloader;
+    const pkg  *kernel;
+    const char *kernel_params;
+} boot_cfg;
+
+typedef struct {
+    uint32_t    schema_version;
+    const char *hostname;
+    boot_cfg    boot;
+    pkg_refs    pkgs;
+    services    services;
+    users       users;
+} system_cfg;
+
+#define SYSTEM_CFG_INIT(...) { \
+    .schema_version = NBOS_SCHEMA_VERSION, \
+    __VA_ARGS__ \
+}
+
+#endif
diff --git a/pkgs/browser/firefox/build.sh b/pkgs/browser/firefox/build.sh
new file mode 100644
index 0000000..55a14ea
--- /dev/null
+++ b/pkgs/browser/firefox/build.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+./mach build
+./mach package
+mkdir -p "$NB_OUT/usr"
+tar -xf obj-*/dist/firefox-*.tar.bz2 -C "$NB_OUT/usr"
diff --git a/pkgs/browser/firefox/firefox.c b/pkgs/browser/firefox/firefox.c
new file mode 100644
index 0000000..cad79a3
--- /dev/null
+++ b/pkgs/browser/firefox/firefox.c
@@ -0,0 +1,14 @@
+#include "firefox.h"
+#include "../../core/glibc/glibc.h"
+
+static const pkg *const firefox_deps[] = { &pkgs_glibc };
+
+const pkg pkgs_firefox = {
+    .name        = "firefox",
+    .version     = "133.0",
+    .src         = "https://archive.mozilla.org/pub/firefox/releases/133.0/source/firefox-133.0.source.tar.xz",
+    .sha256      = "0000000000000000000000000000000000000000000000000000000000000000",
+    .deps        = { .data = firefox_deps, .len = sizeof(firefox_deps) / sizeof(firefox_deps[0]) },
+    .build_flags = "",
+    .build_sys   = BUILD_SHELL,
+};
diff --git a/pkgs/browser/firefox/firefox.h b/pkgs/browser/firefox/firefox.h
new file mode 100644
index 0000000..7c2676c
--- /dev/null
+++ b/pkgs/browser/firefox/firefox.h
@@ -0,0 +1,5 @@
+#ifndef PKGS_FIREFOX_H
+#define PKGS_FIREFOX_H
+#include "../../../include/nbos.h"
+extern const pkg pkgs_firefox;
+#endif
diff --git a/pkgs/core/glibc/build.sh b/pkgs/core/glibc/build.sh
new file mode 100644
index 0000000..e4f6d81
--- /dev/null
+++ b/pkgs/core/glibc/build.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+set -eu
+
+mkdir -p build && cd build
+"$NB_SRCDIR/configure" \
+    --prefix="$NB_PREFIX" \
+    --disable-werror
+make -j"$NB_JOBS"
+make install DESTDIR="$NB_OUT"
diff --git a/pkgs/core/glibc/glibc.c b/pkgs/core/glibc/glibc.c
new file mode 100644
index 0000000..1949db4
--- /dev/null
+++ b/pkgs/core/glibc/glibc.c
@@ -0,0 +1,11 @@
+#include "glibc.h"
+
+const pkg pkgs_glibc = {
+    .name        = "glibc",
+    .version     = "2.40",
+    .src         = "https://ftp.gnu.org/gnu/glibc/glibc-2.40.tar.xz",
+    .sha256      = "0000000000000000000000000000000000000000000000000000000000000000",
+    .deps        = { .data = nullptr, .len = 0 },
+    .build_flags = "",
+    .build_sys   = BUILD_AUTOTOOLS,
+};
diff --git a/pkgs/core/glibc/glibc.h b/pkgs/core/glibc/glibc.h
new file mode 100644
index 0000000..c0eab6a
--- /dev/null
+++ b/pkgs/core/glibc/glibc.h
@@ -0,0 +1,5 @@
+#ifndef PKGS_GLIBC_H
+#define PKGS_GLIBC_H
+#include "../../../include/nbos.h"
+extern const pkg pkgs_glibc;
+#endif
diff --git a/pkgs/core/linux/build.sh b/pkgs/core/linux/build.sh
new file mode 100644
index 0000000..d7000b1
--- /dev/null
+++ b/pkgs/core/linux/build.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -eu
+
+make -j"$NB_JOBS" defconfig
+make -j"$NB_JOBS"
+mkdir -p "$NB_OUT/boot"
+cp arch/x86/boot/bzImage "$NB_OUT/boot/vmlinuz"
+make INSTALL_MOD_PATH="$NB_OUT" modules_install
diff --git a/pkgs/core/linux/linux.c b/pkgs/core/linux/linux.c
new file mode 100644
index 0000000..2f720e0
--- /dev/null
+++ b/pkgs/core/linux/linux.c
@@ -0,0 +1,11 @@
+#include "linux.h"
+
+const pkg pkgs_linux = {
+    .name        = "linux",
+    .version     = "6.12",
+    .src         = "https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.tar.xz",
+    .sha256      = "0000000000000000000000000000000000000000000000000000000000000000",
+    .deps        = { .data = nullptr, .len = 0 },
+    .build_flags = "",
+    .build_sys   = BUILD_MAKE,
+};
diff --git a/pkgs/core/linux/linux.h b/pkgs/core/linux/linux.h
new file mode 100644
index 0000000..5295fc6
--- /dev/null
+++ b/pkgs/core/linux/linux.h
@@ -0,0 +1,5 @@
+#ifndef PKGS_LINUX_H
+#define PKGS_LINUX_H
+#include "../../../include/nbos.h"
+extern const pkg pkgs_linux;
+#endif
diff --git a/pkgs/editor/neovim/build.sh b/pkgs/editor/neovim/build.sh
new file mode 100644
index 0000000..cda8b60
--- /dev/null
+++ b/pkgs/editor/neovim/build.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -eu
+
+cmake -B build -S . \
+    -DCMAKE_BUILD_TYPE=Release \
+    -DCMAKE_INSTALL_PREFIX="$NB_OUT/usr"
+cmake --build build --parallel "$NB_JOBS"
+cmake --install build
diff --git a/pkgs/editor/neovim/neovim.c b/pkgs/editor/neovim/neovim.c
new file mode 100644
index 0000000..dae3833
--- /dev/null
+++ b/pkgs/editor/neovim/neovim.c
@@ -0,0 +1,19 @@
+#include "neovim.h"
+#include "../../core/glibc/glibc.h"
+#include "../../lang/lua/lua.h"
+
+static const pkg *const neovim_deps[] = {
+    &pkgs_glibc,
+    &pkgs_lua,
+};
+
+const pkg pkgs_neovim = {
+    .name        = "neovim",
+    .version     = "0.10.2",
+    .src         = "https://github.com/neovim/neovim/archive/refs/tags/v0.10.2.tar.gz",
+    .sha256      = "0000000000000000000000000000000000000000000000000000000000000000",
+    .deps        = { .data = neovim_deps,
+                     .len = sizeof(neovim_deps) / sizeof(neovim_deps[0]) },
+    .build_flags = "",
+    .build_sys   = BUILD_CMAKE,
+};
diff --git a/pkgs/editor/neovim/neovim.h b/pkgs/editor/neovim/neovim.h
new file mode 100644
index 0000000..c78de21
--- /dev/null
+++ b/pkgs/editor/neovim/neovim.h
@@ -0,0 +1,5 @@
+#ifndef PKGS_NEOVIM_H
+#define PKGS_NEOVIM_H
+#include "../../../include/nbos.h"
+extern const pkg pkgs_neovim;
+#endif
diff --git a/pkgs/lang/lua/build.sh b/pkgs/lang/lua/build.sh
new file mode 100644
index 0000000..ae6b575
--- /dev/null
+++ b/pkgs/lang/lua/build.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+make -j"$NB_JOBS" linux
+make install INSTALL_TOP="$NB_OUT/usr"
diff --git a/pkgs/lang/lua/lua.c b/pkgs/lang/lua/lua.c
new file mode 100644
index 0000000..219a78b
--- /dev/null
+++ b/pkgs/lang/lua/lua.c
@@ -0,0 +1,14 @@
+#include "lua.h"
+#include "../../core/glibc/glibc.h"
+
+static const pkg *const lua_deps[] = { &pkgs_glibc };
+
+const pkg pkgs_lua = {
+    .name        = "lua",
+    .version     = "5.4.7",
+    .src         = "https://www.lua.org/ftp/lua-5.4.7.tar.gz",
+    .sha256      = "0000000000000000000000000000000000000000000000000000000000000000",
+    .deps        = { .data = lua_deps, .len = sizeof(lua_deps) / sizeof(lua_deps[0]) },
+    .build_flags = "",
+    .build_sys   = BUILD_MAKE,
+};
diff --git a/pkgs/lang/lua/lua.h b/pkgs/lang/lua/lua.h
new file mode 100644
index 0000000..e82370d
--- /dev/null
+++ b/pkgs/lang/lua/lua.h
@@ -0,0 +1,5 @@
+#ifndef PKGS_LUA_H
+#define PKGS_LUA_H
+#include "../../../include/nbos.h"
+extern const pkg pkgs_lua;
+#endif
diff --git a/pkgs/shell/dash/build.sh b/pkgs/shell/dash/build.sh
new file mode 100644
index 0000000..683b9ef
--- /dev/null
+++ b/pkgs/shell/dash/build.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./configure --prefix="$NB_PREFIX"
+make -j"$NB_JOBS"
+make install DESTDIR="$NB_OUT"
diff --git a/pkgs/shell/dash/dash.c b/pkgs/shell/dash/dash.c
new file mode 100644
index 0000000..8f1689e
--- /dev/null
+++ b/pkgs/shell/dash/dash.c
@@ -0,0 +1,14 @@
+#include "dash.h"
+#include "../../core/glibc/glibc.h"
+
+static const pkg *const dash_deps[] = { &pkgs_glibc };
+
+const pkg pkgs_dash = {
+    .name        = "dash",
+    .version     = "0.5.12",
+    .src         = "http://gondor.apana.org.au/~herbert/dash/files/dash-0.5.12.tar.gz",
+    .sha256      = "0000000000000000000000000000000000000000000000000000000000000000",
+    .deps        = { .data = dash_deps, .len = sizeof(dash_deps) / sizeof(dash_deps[0]) },
+    .build_flags = "",
+    .build_sys   = BUILD_AUTOTOOLS,
+};
diff --git a/pkgs/shell/dash/dash.h b/pkgs/shell/dash/dash.h
new file mode 100644
index 0000000..9b94c2a
--- /dev/null
+++ b/pkgs/shell/dash/dash.h
@@ -0,0 +1,5 @@
+#ifndef PKGS_DASH_H
+#define PKGS_DASH_H
+#include "../../../include/nbos.h"
+extern const pkg pkgs_dash;
+#endif
diff --git a/pkgs/wm/oxwm/build.sh b/pkgs/wm/oxwm/build.sh
new file mode 100644
index 0000000..0d4acb7
--- /dev/null
+++ b/pkgs/wm/oxwm/build.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+
+zig build install \
+    -Doptimize=ReleaseSafe \
+    --prefix "$NB_OUT/usr"
+
+install -Dm644 resources/oxwm.desktop -t "$NB_OUT/usr/share/xsessions"
+install -Dm644 resources/oxwm.1       -t "$NB_OUT/usr/share/man/man1"
+install -Dm644 templates/oxwm.lua     -t "$NB_OUT/usr/share/oxwm"
diff --git a/pkgs/wm/oxwm/oxwm.c b/pkgs/wm/oxwm/oxwm.c
new file mode 100644
index 0000000..aadb31c
--- /dev/null
+++ b/pkgs/wm/oxwm/oxwm.c
@@ -0,0 +1,18 @@
+#include "oxwm.h"
+#include "../../core/glibc/glibc.h"
+#include "../../lang/lua/lua.h"
+
+static const pkg *const oxwm_deps[] = {
+    &pkgs_glibc,
+    &pkgs_lua,
+};
+
+const pkg pkgs_oxwm = {
+    .name        = "oxwm",
+    .version     = "0.11.4",
+    .src         = "https://github.com/tonybanters/oxwm/archive/refs/tags/v0.11.4.tar.gz",
+    .sha256      = "0000000000000000000000000000000000000000000000000000000000000000",
+    .deps        = { .data = oxwm_deps, .len = sizeof(oxwm_deps) / sizeof(oxwm_deps[0]) },
+    .build_flags = "",
+    .build_sys   = BUILD_ZIG,
+};
diff --git a/pkgs/wm/oxwm/oxwm.h b/pkgs/wm/oxwm/oxwm.h
new file mode 100644
index 0000000..b83b879
--- /dev/null
+++ b/pkgs/wm/oxwm/oxwm.h
@@ -0,0 +1,5 @@
+#ifndef PKGS_OXWM_H
+#define PKGS_OXWM_H
+#include "../../../include/nbos.h"
+extern const pkg pkgs_oxwm;
+#endif
diff --git a/src/.#build.c b/src/.#build.c
new file mode 120000
index 0000000..26744b6
--- /dev/null
+++ b/src/.#build.c
@@ -0,0 +1 @@
+tony@nixos-btw.116666:1777952016
\ No newline at end of file
diff --git a/src/activate.c b/src/activate.c
new file mode 100644
index 0000000..c174cdf
--- /dev/null
+++ b/src/activate.c
@@ -0,0 +1,43 @@
+#define _GNU_SOURCE
+#include "activate.h"
+
+#include <errno.h>
+#include <stdio.h>
+
+/**
+ * activate() - Build and activate a generation.
+ * @a: Arena for transient allocations.
+ * @cfg: System configuration to activate.
+ * @resolved: Topologically-sorted resolved package list.
+ * @generation: Generation number to assign.
+ *
+ * Symlinks resolved store paths into /nb/system/<gen>/, updates the
+ * bootloader, atomically swaps /nb/system/current, and runs service
+ * activations. On failure at any step, the previous generation
+ * remains active.
+ *
+ * Return: 0 on success, errno value on failure.
+ */
+int activate(
+    arena               *a,
+    const system_cfg    *cfg,
+    const resolved_list *resolved,
+    uint32_t             generation)
+{
+    (void)a;
+    (void)cfg;
+    (void)resolved;
+    (void)generation;
+    return ENOSYS;
+}
+
+/**
+ * activate_rollback() - Activate the previous generation.
+ * @a: Arena for transient allocations.
+ *
+ * Return: 0 on success, errno value on failure.
+ */
+int activate_rollback(arena *a) {
+    (void)a;
+    return ENOSYS;
+}
diff --git a/src/activate.h b/src/activate.h
new file mode 100644
index 0000000..b132fbc
--- /dev/null
+++ b/src/activate.h
@@ -0,0 +1,18 @@
+#ifndef NB_ACTIVATE_H
+#define NB_ACTIVATE_H
+
+#include "../include/nbos.h"
+#include "arena.h"
+#include "resolve.h"
+
+#include <stdint.h>
+
+[[nodiscard]] int activate(
+    arena               *a,
+    const system_cfg    *cfg,
+    const resolved_list *resolved,
+    uint32_t             generation);
+
+[[nodiscard]] int activate_rollback(arena *a);
+
+#endif
diff --git a/src/arena.c b/src/arena.c
new file mode 100644
index 0000000..f6b3d67
--- /dev/null
+++ b/src/arena.c
@@ -0,0 +1,177 @@
+#define _GNU_SOURCE
+#include "arena.h"
+
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct block block;
+struct block {
+    block  *next;
+    size_t  capacity;
+    size_t  used;
+};
+
+struct arena {
+    block  *head;
+    block  *first;
+    size_t  total_used;
+    size_t  total_cap;
+};
+
+static block *block_create(size_t capacity) {
+    block *b = malloc(sizeof(block) + capacity);
+    if (b == nullptr) return nullptr;
+    b->next = nullptr;
+    b->capacity = capacity;
+    b->used = 0;
+    return b;
+}
+
+/**
+ * arena_create() - Allocate a new arena with one initial block.
+ * @initial_capacity: Bytes of usable space in the first block.
+ *
+ * Return: Arena pointer, or NULL on allocation failure.
+ */
+arena *arena_create(size_t initial_capacity) {
+    arena *a = malloc(sizeof(arena));
+    if (a == nullptr) return nullptr;
+
+    block *b = block_create(initial_capacity);
+    if (b == nullptr) {
+        free(a);
+        return nullptr;
+    }
+
+    a->head = b;
+    a->first = b;
+    a->total_used = 0;
+    a->total_cap = initial_capacity;
+    return a;
+}
+
+/**
+ * arena_destroy() - Free every block held by the arena.
+ * @a: Arena to free. NULL is permitted.
+ */
+void arena_destroy(arena *a) {
+    if (a == nullptr) return;
+    block *b = a->first;
+    while (b != nullptr) {
+        block *next = b->next;
+        free(b);
+        b = next;
+    }
+    free(a);
+}
+
+/**
+ * arena_alloc() - Bump-allocate an aligned chunk from the arena.
+ * @a: Arena to allocate from.
+ * @size: Number of bytes to reserve.
+ * @align: Required alignment in bytes (power of two).
+ *
+ * A new block at twice the current capacity (or larger if the request
+ * itself is larger) is allocated when the head block is exhausted.
+ *
+ * Return: Pointer to @size aligned bytes, or NULL on allocation failure.
+ */
+void *arena_alloc(arena *a, size_t size, size_t align) {
+    if (a == nullptr || size == 0) return nullptr;
+
+    uintptr_t base = (uintptr_t)((char *)(a->head + 1) + a->head->used);
+    uintptr_t aligned = (base + (align - 1)) & ~(align - 1);
+    size_t pad = aligned - base;
+
+    if (a->head->used + pad + size <= a->head->capacity) {
+        a->head->used += pad + size;
+        a->total_used += pad + size;
+        return (void *)aligned;
+    }
+
+    size_t new_cap = a->head->capacity * 2;
+    if (new_cap < size + align) new_cap = size + align;
+
+    block *b = block_create(new_cap);
+    if (b == nullptr) return nullptr;
+
+    b->next = a->head;
+    a->head = b;
+    a->total_cap += new_cap;
+
+    base = (uintptr_t)(b + 1);
+    aligned = (base + (align - 1)) & ~(align - 1);
+    pad = aligned - base;
+
+    b->used = pad + size;
+    a->total_used += pad + size;
+    return (void *)aligned;
+}
+
+/**
+ * arena_strdup() - Duplicate a NUL-terminated string into the arena.
+ * @a: Arena to allocate from.
+ * @s: Source string. NULL yields NULL.
+ *
+ * Return: Owned-by-arena copy of @s, or NULL on allocation failure.
+ */
+char *arena_strdup(arena *a, const char *s) {
+    if (s == nullptr) return nullptr;
+    size_t len = strlen(s) + 1;
+    char *out = arena_alloc(a, len, 1);
+    if (out == nullptr) return nullptr;
+    memcpy(out, s, len);
+    return out;
+}
+
+/**
+ * arena_sprintf() - printf into a fresh arena allocation.
+ * @a: Arena to allocate from.
+ * @fmt: printf format string.
+ *
+ * Return: Owned-by-arena formatted string, or NULL on allocation failure.
+ */
+char *arena_sprintf(arena *a, const char *fmt, ...) {
+    va_list ap;
+
+    va_start(ap, fmt);
+    int len = vsnprintf(nullptr, 0, fmt, ap);
+    va_end(ap);
+    if (len < 0) return nullptr;
+
+    char *out = arena_alloc(a, (size_t)len + 1, 1);
+    if (out == nullptr) return nullptr;
+
+    va_start(ap, fmt);
+    vsnprintf(out, (size_t)len + 1, fmt, ap);
+    va_end(ap);
+    return out;
+}
+
+/**
+ * arena_reset() - Rewind the arena to empty without releasing memory.
+ * @a: Arena to reset. NULL is permitted.
+ *
+ * Frees every block except the first; the first block's used pointer is
+ * reset to zero.
+ */
+void arena_reset(arena *a) {
+    if (a == nullptr) return;
+
+    block *b = a->head;
+    while (b != a->first) {
+        block *next = b->next;
+        free(b);
+        b = next;
+    }
+    a->head = a->first;
+    a->first->used = 0;
+    a->total_used = 0;
+    a->total_cap = a->first->capacity;
+}
+
+size_t arena_used(const arena *a)     { return a == nullptr ? 0 : a->total_used; }
+size_t arena_capacity(const arena *a) { return a == nullptr ? 0 : a->total_cap; }
diff --git a/src/arena.h b/src/arena.h
new file mode 100644
index 0000000..5ebfbfe
--- /dev/null
+++ b/src/arena.h
@@ -0,0 +1,20 @@
+#ifndef NB_ARENA_H
+#define NB_ARENA_H
+
+#include <stddef.h>
+
+typedef struct arena arena;
+
+[[nodiscard]] arena *arena_create(size_t initial_capacity);
+void  arena_destroy(arena *a);
+
+[[nodiscard]] void *arena_alloc(arena *a, size_t size, size_t align);
+[[nodiscard]] char *arena_strdup(arena *a, const char *s);
+[[nodiscard]] char *arena_sprintf(arena *a, const char *fmt, ...);
+
+void arena_reset(arena *a);
+
+size_t arena_used(const arena *a);
+size_t arena_capacity(const arena *a);
+
+#endif
diff --git a/src/build.c b/src/build.c
new file mode 100644
index 0000000..eb865a2
--- /dev/null
+++ b/src/build.c
@@ -0,0 +1,125 @@
+#define _GNU_SOURCE
+#include "build.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include "fetch.h"
+#include "run.h"
+#include "sandbox.h"
+#include "store.h"
+
+#define WORK_ROOT "/var/lib/nb/work"
+
+/**
+ * build_pkg() - Realize one package into the store.
+ * @a: Arena for path scratch space.
+ * @p: Package to build.
+ * @all_pkgs: Full package list for the system (currently unused).
+ * @resolved_pkgs: Topologically-sorted resolved entries.
+ * @pkg_idx: Index of @p within @resolved_pkgs.
+ *
+ * Idempotent: returns success immediately if the store path already
+ * exists. Otherwise fetches the source tarball, extracts it, sets up
+ * a sandbox containing the resolved deps, runs the build, and atomically
+ * moves the output into the store.
+ *
+ * Return: REALIZE_OK_VAL on success, a tagged error otherwise.
+ */
+realize_error build_pkg(
+    arena              *a,
+    const pkg          *p,
+    const pkg_refs     *all_pkgs,
+    const resolved     *resolved_pkgs,
+    size_t              pkg_idx)
+{
+    (void)all_pkgs;
+
+    const char *store_path = resolved_pkgs[pkg_idx].store_path;
+
+    if (store_path_exists(store_path)) {
+        return REALIZE_OK_VAL;
+    }
+
+    const char *base = strrchr(store_path, '/');
+    base = (base != nullptr) ? base + 1 : store_path;
+
+    char *work_dir     = arena_sprintf(a, "%s/%s", WORK_ROOT, base);
+    char *src_tarball  = arena_sprintf(a, "%s/source", work_dir);
+    char *src_dir      = arena_sprintf(a, "%s/build", work_dir);
+    char *sandbox_root = arena_sprintf(a, "%s/sandbox", work_dir);
+    char *log_path     = arena_sprintf(a, "%s/build.log", work_dir);
+    char *out_dir      = arena_sprintf(a, "%s/out", work_dir);
+
+    if (work_dir == nullptr || src_tarball == nullptr || src_dir == nullptr || sandbox_root == nullptr || log_path == nullptr || out_dir == nullptr) {
+        return (realize_error){
+            .kind = REALIZE_E_STORE,
+            .pkg_name = p->name,
+            .errno_val = ENOMEM,
+        };
+    }
+
+    if (mkdir(WORK_ROOT, 0755) < 0 && errno != EEXIST) {
+        return (realize_error){ .kind = REALIZE_E_STORE, .pkg_name = p->name,
+                                .errno_val = errno };
+    }
+    if (mkdir(work_dir, 0755) < 0 && errno != EEXIST) {
+        return (realize_error){ .kind = REALIZE_E_STORE, .pkg_name = p->name,
+                                .errno_val = errno };
+    }
+
+    fetch_error ferr = fetch(p, src_tarball);
+    if (ferr.kind != FETCH_OK) {
+        return (realize_error){
+            .kind = REALIZE_E_FETCH,
+            .pkg_name = p->name,
+            .fetch = ferr,
+        };
+    }
+
+    if (mkdir(src_dir, 0755) < 0 && errno != EEXIST) {
+        return (realize_error){ .kind = REALIZE_E_STORE, .pkg_name = p->name,
+                                .errno_val = errno };
+    }
+
+    char *const tar_argv[] = {
+        "/usr/bin/tar", "-xf", src_tarball, "-C", src_dir, "--strip-components=1",
+        nullptr,
+    };
+    char *const tar_envp[] = { nullptr };
+
+    run_error tar_err = run("/usr/bin/tar", tar_argv, tar_envp, nullptr, log_path);
+    if (tar_err.kind != RUN_OK) {
+        return (realize_error){
+            .kind = REALIZE_E_BUILD,
+            .pkg_name = p->name,
+            .run = tar_err,
+        };
+    }
+
+    int src = sandbox_setup(sandbox_root, &p->deps, resolved_pkgs, src_dir);
+    if (src != 0) {
+        return (realize_error){
+            .kind = REALIZE_E_SANDBOX,
+            .pkg_name = p->name,
+            .errno_val = src,
+        };
+    }
+
+    sandbox_teardown(sandbox_root);
+
+    int rc = store_install(out_dir, store_path);
+    if (rc != 0) {
+        return (realize_error){
+            .kind = REALIZE_E_STORE,
+            .pkg_name = p->name,
+            .errno_val = rc,
+        };
+    }
+
+    return REALIZE_OK_VAL;
+}
diff --git a/src/build.h b/src/build.h
new file mode 100644
index 0000000..2bfd43d
--- /dev/null
+++ b/src/build.h
@@ -0,0 +1,16 @@
+#ifndef NB_BUILD_H
+#define NB_BUILD_H
+
+#include "../include/nbos.h"
+#include "arena.h"
+#include "error.h"
+#include "resolve.h"
+
+[[nodiscard]] realize_error build_pkg(
+    arena              *a,
+    const pkg          *p,
+    const pkg_refs     *all_pkgs,
+    const resolved     *resolved_pkgs,
+    size_t              pkg_idx);
+
+#endif
diff --git a/src/error.c b/src/error.c
new file mode 100644
index 0000000..95e0494
--- /dev/null
+++ b/src/error.c
@@ -0,0 +1,79 @@
+#include "error.h"
+
+#include <stdio.h>
+#include <string.h>
+
+void resolve_error_print(const resolve_error *e) {
+    switch (e->kind) {
+        case RESOLVE_OK:
+            return;
+        case RESOLVE_E_CYCLE:
+            fprintf(stderr, "nb: dependency cycle detected at '%s'\n", e->pkg_name);
+            return;
+        case RESOLVE_E_MISSING_DEP:
+            fprintf(stderr, "nb: package '%s' depends on '%s' which is not in the package list\n", e->pkg_name, e->dep_name);
+            return;
+        case RESOLVE_E_DUPLICATE_NAME:
+            fprintf(stderr, "nb: duplicate package name '%s' (two recipes export the same name)\n", e->pkg_name);
+            return;
+    }
+}
+
+void fetch_error_print(const fetch_error *e) {
+    switch (e->kind) {
+        case FETCH_OK:
+            return;
+        case FETCH_E_NETWORK:
+            fprintf(stderr, "nb: network error fetching %s: %s\n", e->url, strerror(e->errno_val));
+            return;
+        case FETCH_E_HASH_MISMATCH:
+            fprintf(stderr, "nb: hash mismatch for %s\n", e->url);
+            fprintf(stderr, "  expected: %s\n", e->expected_sha);
+            fprintf(stderr, "  actual:   %s\n", e->actual_sha);
+            return;
+        case FETCH_E_IO:
+            fprintf(stderr, "nb: I/O error fetching %s: %s\n", e->url, strerror(e->errno_val));
+            return;
+    }
+}
+
+void run_error_print(const run_error *e) {
+    switch (e->kind) {
+        case RUN_OK:
+            return;
+        case RUN_E_SPAWN:
+            fprintf(stderr, "nb: failed to spawn process: %s\n",
+                    strerror(e->errno_val));
+            return;
+        case RUN_E_NONZERO:
+            fprintf(stderr, "nb: process exited %d (log: %s)\n",
+                    e->exit_code, e->log_path);
+            return;
+        case RUN_E_IO:
+            fprintf(stderr, "nb: I/O error: %s\n", strerror(e->errno_val));
+            return;
+    }
+}
+
+void realize_error_print(const realize_error *e) {
+    switch (e->kind) {
+        case REALIZE_OK:
+            return;
+        case REALIZE_E_FETCH:
+            fprintf(stderr, "nb: failed to realize '%s' (fetch):\n  ", e->pkg_name);
+            fetch_error_print(&e->fetch);
+            return;
+        case REALIZE_E_BUILD:
+            fprintf(stderr, "nb: failed to realize '%s' (build):\n  ", e->pkg_name);
+            run_error_print(&e->run);
+            return;
+        case REALIZE_E_SANDBOX:
+            fprintf(stderr, "nb: failed to set up sandbox for '%s': %s\n",
+                    e->pkg_name, strerror(e->errno_val));
+            return;
+        case REALIZE_E_STORE:
+            fprintf(stderr, "nb: store error for '%s': %s\n",
+                    e->pkg_name, strerror(e->errno_val));
+            return;
+    }
+}
diff --git a/src/error.h b/src/error.h
new file mode 100644
index 0000000..18fa73a
--- /dev/null
+++ b/src/error.h
@@ -0,0 +1,80 @@
+#ifndef NB_ERROR_H
+#define NB_ERROR_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+typedef enum : uint8_t {
+    RESOLVE_OK = 0,
+    RESOLVE_E_CYCLE,
+    RESOLVE_E_MISSING_DEP,
+    RESOLVE_E_DUPLICATE_NAME,
+} resolve_error_kind;
+
+typedef struct {
+    resolve_error_kind kind;
+    const char        *pkg_name;
+    const char        *dep_name;
+} resolve_error;
+
+#define RESOLVE_OK_VAL ((resolve_error){ .kind = RESOLVE_OK })
+
+typedef enum : uint8_t {
+    FETCH_OK = 0,
+    FETCH_E_NETWORK,
+    FETCH_E_HASH_MISMATCH,
+    FETCH_E_IO,
+} fetch_error_kind;
+
+typedef struct {
+    fetch_error_kind kind;
+    const char      *url;
+    const char      *expected_sha;
+    const char      *actual_sha;
+    int              errno_val;
+} fetch_error;
+
+#define FETCH_OK_VAL ((fetch_error){ .kind = FETCH_OK })
+
+typedef enum : uint8_t {
+    RUN_OK = 0,
+    RUN_E_SPAWN,
+    RUN_E_NONZERO,
+    RUN_E_IO,
+} run_error_kind;
+
+typedef struct {
+    run_error_kind kind;
+    int            exit_code;
+    const char    *log_path;
+    int            errno_val;
+} run_error;
+
+#define RUN_OK_VAL ((run_error){ .kind = RUN_OK })
+
+typedef enum : uint8_t {
+    REALIZE_OK = 0,
+    REALIZE_E_FETCH,
+    REALIZE_E_BUILD,
+    REALIZE_E_SANDBOX,
+    REALIZE_E_STORE,
+} realize_error_kind;
+
+typedef struct {
+    realize_error_kind kind;
+    const char        *pkg_name;
+    union {
+        fetch_error fetch;
+        run_error   run;
+        int         errno_val;
+    };
+} realize_error;
+
+#define REALIZE_OK_VAL ((realize_error){ .kind = REALIZE_OK })
+
+void resolve_error_print(const resolve_error *e);
+void fetch_error_print(const fetch_error *e);
+void run_error_print(const run_error *e);
+void realize_error_print(const realize_error *e);
+
+#endif
diff --git a/src/fetch.c b/src/fetch.c
new file mode 100644
index 0000000..9ab7bdb
--- /dev/null
+++ b/src/fetch.c
@@ -0,0 +1,69 @@
+#define _GNU_SOURCE
+#include "fetch.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "hash.h"
+#include "run.h"
+
+/**
+ * fetch() - Download a package's source tarball and verify its hash.
+ * @p: Package whose @src URL and @sha256 are used.
+ * @dest_path: Destination path on disk.
+ *
+ * Shells out to curl(1) so nb does not link libcurl. The downloaded
+ * file's sha256 is compared against @p->sha256.
+ *
+ * Return: FETCH_OK_VAL on success, a tagged error otherwise.
+ */
+fetch_error fetch(const pkg *p, const char *dest_path) {
+    char *const argv[] = {
+        "/usr/bin/curl",
+        "-fsSL",
+        "--retry", "3",
+        "--retry-delay", "2",
+        "-o", (char *)dest_path,
+        (char *)p->src,
+        nullptr,
+    };
+
+    char *const envp[] = { nullptr };
+
+    char log_path[4096];
+    int n = snprintf(log_path, sizeof(log_path), "%s.fetch.log", dest_path);
+    if (n < 0 || (size_t)n >= sizeof(log_path)) {
+        return (fetch_error){
+            .kind = FETCH_E_IO,
+            .url = p->src,
+            .errno_val = ENAMETOOLONG,
+        };
+    }
+
+    run_error rerr = run("/usr/bin/curl", argv, envp, nullptr, log_path);
+    if (rerr.kind != RUN_OK) {
+        fetch_error fe = {
+            .kind = FETCH_E_NETWORK,
+            .url = p->src,
+            .errno_val = 0,
+        };
+        if (rerr.kind == RUN_E_SPAWN || rerr.kind == RUN_E_IO) {
+            fe.kind = FETCH_E_IO;
+            fe.errno_val = rerr.errno_val;
+        }
+        return fe;
+    }
+
+    char actual[65] = {0};
+    if (!sha256_verify_file(dest_path, p->sha256, actual)) {
+        return (fetch_error){
+            .kind = FETCH_E_HASH_MISMATCH,
+            .url = p->src,
+            .expected_sha = p->sha256,
+            .actual_sha = strdup(actual),
+        };
+    }
+
+    return FETCH_OK_VAL;
+}
diff --git a/src/fetch.h b/src/fetch.h
new file mode 100644
index 0000000..0e6d39f
--- /dev/null
+++ b/src/fetch.h
@@ -0,0 +1,11 @@
+#ifndef NB_FETCH_H
+#define NB_FETCH_H
+
+#include "../include/nbos.h"
+#include "error.h"
+
+[[nodiscard]] fetch_error fetch(
+    const pkg  *p,
+    const char *dest_path);
+
+#endif
diff --git a/src/hash.c b/src/hash.c
new file mode 100644
index 0000000..6c8d034
--- /dev/null
+++ b/src/hash.c
@@ -0,0 +1,196 @@
+#define _GNU_SOURCE
+#include "hash.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+static const uint32_t K[64] = {
+    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
+    0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
+    0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
+    0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
+    0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
+    0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
+    0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
+    0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+    0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+};
+
+#define ROTR(x, n)   (((x) >> (n)) | ((x) << (32 - (n))))
+#define CH(x, y, z)  (((x) & (y)) ^ (~(x) & (z)))
+#define MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
+#define BSIG0(x)     (ROTR(x,  2) ^ ROTR(x, 13) ^ ROTR(x, 22))
+#define BSIG1(x)     (ROTR(x,  6) ^ ROTR(x, 11) ^ ROTR(x, 25))
+#define SSIG0(x)     (ROTR(x,  7) ^ ROTR(x, 18) ^ ((x) >>  3))
+#define SSIG1(x)     (ROTR(x, 17) ^ ROTR(x, 19) ^ ((x) >> 10))
+
+static void sha256_compress(sha256_ctx *ctx, const uint8_t block[64]) {
+    uint32_t W[64];
+    uint32_t a, b, c, d, e, f, g, h;
+    uint32_t T1, T2;
+
+    for (int i = 0; i < 16; i++) {
+        W[i] = ((uint32_t)block[i * 4]     << 24) |
+               ((uint32_t)block[i * 4 + 1] << 16) |
+               ((uint32_t)block[i * 4 + 2] <<  8) |
+               ((uint32_t)block[i * 4 + 3]);
+    }
+    for (int i = 16; i < 64; i++) {
+        W[i] = SSIG1(W[i - 2]) + W[i - 7] + SSIG0(W[i - 15]) + W[i - 16];
+    }
+
+    a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3];
+    e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7];
+
+    for (int i = 0; i < 64; i++) {
+        T1 = h + BSIG1(e) + CH(e, f, g) + K[i] + W[i];
+        T2 = BSIG0(a) + MAJ(a, b, c);
+        h = g; g = f; f = e; e = d + T1;
+        d = c; c = b; b = a; a = T1 + T2;
+    }
+
+    ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d;
+    ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h;
+}
+
+/**
+ * sha256_init() - Initialize a SHA-256 hashing context.
+ * @ctx: Context to initialize.
+ */
+void sha256_init(sha256_ctx *ctx) {
+    ctx->state[0] = 0x6a09e667; ctx->state[1] = 0xbb67ae85;
+    ctx->state[2] = 0x3c6ef372; ctx->state[3] = 0xa54ff53a;
+    ctx->state[4] = 0x510e527f; ctx->state[5] = 0x9b05688c;
+    ctx->state[6] = 0x1f83d9ab; ctx->state[7] = 0x5be0cd19;
+    ctx->bitcount = 0;
+    ctx->buffer_used = 0;
+}
+
+/**
+ * sha256_update() - Feed data into a SHA-256 context.
+ * @ctx: Context previously initialized with sha256_init().
+ * @data: Bytes to hash.
+ * @len: Length of @data in bytes.
+ */
+void sha256_update(sha256_ctx *ctx, const void *data, size_t len) {
+    const uint8_t *p = data;
+    while (len > 0) {
+        size_t n = 64 - ctx->buffer_used;
+        if (n > len) n = len;
+        memcpy(ctx->buffer + ctx->buffer_used, p, n);
+        ctx->buffer_used += n;
+        p   += n;
+        len -= n;
+        if (ctx->buffer_used == 64) {
+            sha256_compress(ctx, ctx->buffer);
+            ctx->bitcount += 512;
+            ctx->buffer_used = 0;
+        }
+    }
+}
+
+/**
+ * sha256_final() - Pad and finalize, writing the digest.
+ * @ctx: Context to finalize. Caller must not reuse without re-init.
+ * @out: 32-byte buffer to receive the digest.
+ */
+void sha256_final(sha256_ctx *ctx, uint8_t out[32]) {
+    ctx->bitcount += (uint64_t)ctx->buffer_used * 8;
+    ctx->buffer[ctx->buffer_used++] = 0x80;
+    if (ctx->buffer_used > 56) {
+        while (ctx->buffer_used < 64) ctx->buffer[ctx->buffer_used++] = 0;
+        sha256_compress(ctx, ctx->buffer);
+        ctx->buffer_used = 0;
+    }
+    while (ctx->buffer_used < 56) ctx->buffer[ctx->buffer_used++] = 0;
+    for (int i = 7; i >= 0; i--) {
+        ctx->buffer[ctx->buffer_used++] = (uint8_t)(ctx->bitcount >> (i * 8));
+    }
+    sha256_compress(ctx, ctx->buffer);
+
+    for (int i = 0; i < 8; i++) {
+        out[i * 4]     = (uint8_t)(ctx->state[i] >> 24);
+        out[i * 4 + 1] = (uint8_t)(ctx->state[i] >> 16);
+        out[i * 4 + 2] = (uint8_t)(ctx->state[i] >>  8);
+        out[i * 4 + 3] = (uint8_t)(ctx->state[i]);
+    }
+}
+
+/**
+ * sha256_hash() - One-shot SHA-256 over a single buffer.
+ * @data: Bytes to hash.
+ * @len: Length of @data in bytes.
+ * @out: 32-byte buffer to receive the digest.
+ */
+void sha256_hash(const void *data, size_t len, uint8_t out[32]) {
+    sha256_ctx ctx;
+    sha256_init(&ctx);
+    sha256_update(&ctx, data, len);
+    sha256_final(&ctx, out);
+}
+
+/**
+ * sha256_hex() - Hex-encode a SHA-256 digest.
+ * @digest: 32-byte digest.
+ * @out: 65-byte buffer to receive the lowercase hex string and NUL.
+ */
+void sha256_hex(const uint8_t digest[32], char out[65]) {
+    static const char hex[] = "0123456789abcdef";
+    for (int i = 0; i < 32; i++) {
+        out[i * 2]     = hex[digest[i] >> 4];
+        out[i * 2 + 1] = hex[digest[i] & 0xf];
+    }
+    out[64] = '\0';
+}
+
+/**
+ * sha256_verify_file() - Compare a file's SHA-256 against an expected hex.
+ * @path: Filesystem path to read.
+ * @expected_hex: Expected lowercase hex digest, or NULL to skip comparison.
+ * @actual_hex: Optional 65-byte buffer to receive the computed hex digest.
+ *
+ * Return: true if the file hashes to @expected_hex (or @expected_hex is NULL
+ * and the file was readable); false on mismatch or I/O error.
+ */
+bool sha256_verify_file(
+    const char *path,
+    const char *expected_hex,
+    char        actual_hex[65])
+{
+    int fd = open(path, O_RDONLY | O_CLOEXEC);
+    if (fd < 0) return false;
+
+    sha256_ctx ctx;
+    sha256_init(&ctx);
+
+    uint8_t buf[8192];
+    for (;;) {
+        ssize_t n = read(fd, buf, sizeof(buf));
+        if (n < 0) {
+            close(fd);
+            return false;
+        }
+        if (n == 0) break;
+        sha256_update(&ctx, buf, (size_t)n);
+    }
+    close(fd);
+
+    uint8_t digest[32];
+    sha256_final(&ctx, digest);
+
+    char hex[65];
+    sha256_hex(digest, hex);
+    if (actual_hex != nullptr) memcpy(actual_hex, hex, 65);
+
+    if (expected_hex == nullptr) return true;
+    return strcmp(hex, expected_hex) == 0;
+}
diff --git a/src/hash.h b/src/hash.h
new file mode 100644
index 0000000..0e9478f
--- /dev/null
+++ b/src/hash.h
@@ -0,0 +1,26 @@
+#ifndef NB_HASH_H
+#define NB_HASH_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+typedef struct {
+    uint32_t state[8];
+    uint64_t bitcount;
+    uint8_t  buffer[64];
+    size_t   buffer_used;
+} sha256_ctx;
+
+void sha256_init  (sha256_ctx *ctx);
+void sha256_update(sha256_ctx *ctx, const void *data, size_t len);
+void sha256_final (sha256_ctx *ctx, uint8_t out[32]);
+
+void sha256_hash(const void *data, size_t len, uint8_t out[32]);
+void sha256_hex(const uint8_t digest[32], char out[65]);
+
+[[nodiscard]] bool sha256_verify_file(
+    const char *path,
+    const char *expected_hex,
+    char        actual_hex[65]);
+
+#endif
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..a97cc03
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,128 @@
+#define _GNU_SOURCE
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "../include/nbos.h"
+#include "activate.h"
+#include "arena.h"
+#include "error.h"
+#include "realize.h"
+#include "resolve.h"
+#include "validate.h"
+
+extern const system_cfg CFG;
+
+static uint32_t next_generation_number(void) {
+    return 1;
+}
+
+static int cmd_switch(void) {
+    if (CFG.schema_version != NBOS_SCHEMA_VERSION) {
+        fprintf(stderr,
+                "nb: config schema version %u does not match nb %u\n",
+                CFG.schema_version, NBOS_SCHEMA_VERSION);
+        return 1;
+    }
+
+    arena *a = arena_create(1 << 20);
+    if (a == nullptr) {
+        fprintf(stderr, "nb: arena alloc failed\n");
+        return 1;
+    }
+
+    fprintf(stderr, "nb: validating config\n");
+    if (!validate(&CFG)) {
+        arena_destroy(a);
+        return 1;
+    }
+
+    fprintf(stderr, "nb: resolving %zu packages\n", CFG.pkgs.len);
+    resolved_list resolved = {0};
+    resolve_error rerr = resolve(a, &CFG, &resolved);
+    if (rerr.kind != RESOLVE_OK) {
+        resolve_error_print(&rerr);
+        arena_destroy(a);
+        return 1;
+    }
+
+    fprintf(stderr, "nb: realizing\n");
+    realize_error realerr = realize(a, &CFG.pkgs, &resolved);
+    if (realerr.kind != REALIZE_OK) {
+        realize_error_print(&realerr);
+        arena_destroy(a);
+        return 1;
+    }
+
+    uint32_t gen = next_generation_number();
+    fprintf(stderr, "nb: activating generation %u\n", gen);
+    int aerr = activate(a, &CFG, &resolved, gen);
+    if (aerr != 0) {
+        fprintf(stderr, "nb: activation failed: %s\n", strerror(aerr));
+        arena_destroy(a);
+        return 1;
+    }
+
+    fprintf(stderr, "nb: switch complete (generation %u)\n", gen);
+    arena_destroy(a);
+    return 0;
+}
+
+static int cmd_validate(void) {
+    if (CFG.schema_version != NBOS_SCHEMA_VERSION) {
+        fprintf(stderr, "nb: schema version mismatch\n");
+        return 1;
+    }
+    if (!validate(&CFG)) return 1;
+
+    arena *a = arena_create(1 << 20);
+    if (a == nullptr) return 1;
+
+    resolved_list resolved = {0};
+    resolve_error rerr = resolve(a, &CFG, &resolved);
+    if (rerr.kind != RESOLVE_OK) {
+        resolve_error_print(&rerr);
+        arena_destroy(a);
+        return 1;
+    }
+
+    fprintf(stderr, "nb: config is valid (%zu pkgs, %zu services, %zu users)\n",
+            CFG.pkgs.len, CFG.services.len, CFG.users.len);
+    arena_destroy(a);
+    return 0;
+}
+
+static int cmd_rollback(void) {
+    arena *a = arena_create(1 << 16);
+    if (a == nullptr) return 1;
+    int rc = activate_rollback(a);
+    arena_destroy(a);
+    return rc == 0 ? 0 : 1;
+}
+
+static void usage(void) {
+    fprintf(stderr,
+        "usage: nb <command>\n"
+        "\n"
+        "commands:\n"
+        "  switch     build and activate the current config\n"
+        "  validate   check the current config without activating\n"
+        "  rollback   activate the previous generation\n"
+        "  diff       diff two generations (default: previous vs current)\n"
+        "  gc         remove store paths not referenced by any generation\n");
+}
+
+int main(int argc, char **argv) {
+    if (argc < 2) { usage(); return 1; }
+
+    const char *cmd = argv[1];
+
+    if (strcmp(cmd, "switch") == 0)   return cmd_switch();
+    if (strcmp(cmd, "validate") == 0) return cmd_validate();
+    if (strcmp(cmd, "rollback") == 0) return cmd_rollback();
+
+    fprintf(stderr, "nb: unknown command '%s'\n", cmd);
+    usage();
+    return 1;
+}
diff --git a/src/realize.c b/src/realize.c
new file mode 100644
index 0000000..4aa3b01
--- /dev/null
+++ b/src/realize.c
@@ -0,0 +1,29 @@
+#include "realize.h"
+
+#include "build.h"
+
+/**
+ * realize() - Build every resolved package into the store.
+ * @a: Arena for transient allocations.
+ * @all_pkgs: Full package list (passed through to build_pkg()).
+ * @resolved: Topologically-sorted resolved list. Builds happen in order.
+ *
+ * Stops at the first failure and returns its error.
+ *
+ * Return: REALIZE_OK_VAL on success, the failing package's error otherwise.
+ */
+realize_error realize(
+    arena              *a,
+    const pkg_refs     *all_pkgs,
+    const resolved_list *resolved)
+{
+    for (size_t i = 0; i < resolved->len; i++) {
+        realize_error err = build_pkg(a,
+                                       resolved->data[i].def,
+                                       all_pkgs,
+                                       resolved->data,
+                                       i);
+        if (err.kind != REALIZE_OK) return err;
+    }
+    return REALIZE_OK_VAL;
+}
diff --git a/src/realize.h b/src/realize.h
new file mode 100644
index 0000000..dccd908
--- /dev/null
+++ b/src/realize.h
@@ -0,0 +1,14 @@
+#ifndef NB_REALIZE_H
+#define NB_REALIZE_H
+
+#include "../include/nbos.h"
+#include "arena.h"
+#include "error.h"
+#include "resolve.h"
+
+[[nodiscard]] realize_error realize(
+    arena              *a,
+    const pkg_refs     *all_pkgs,
+    const resolved_list *resolved);
+
+#endif
diff --git a/src/resolve.c b/src/resolve.c
new file mode 100644
index 0000000..74625ae
--- /dev/null
+++ b/src/resolve.c
@@ -0,0 +1,108 @@
+#include "resolve.h"
+
+#include <stdint.h>
+#include <stdalign.h>
+#include <string.h>
+
+#include "hash.h"
+#include "store.h"
+
+typedef enum : uint8_t {
+    UNVISITED = 0,
+    VISITING,
+    VISITED,
+} visit_state;
+
+static size_t find_pkg(const pkg_refs *pkgs, const pkg *needle) {
+    for (size_t i = 0; i < pkgs->len; i++) {
+        if (pkgs->data[i] == needle) return i;
+    }
+    return SIZE_MAX;
+}
+
+static resolve_error visit(
+    arena              *a,
+    const pkg_refs     *pkgs,
+    size_t              idx,
+    visit_state        *state,
+    resolved           *out)
+{
+    switch (state[idx]) {
+        case VISITED:  return RESOLVE_OK_VAL;
+        case VISITING: return (resolve_error){
+            .kind = RESOLVE_E_CYCLE,
+            .pkg_name = pkgs->data[idx]->name,
+        };
+        case UNVISITED: break;
+    }
+
+    state[idx] = VISITING;
+    const pkg *p = pkgs->data[idx];
+
+    for (size_t i = 0; i < p->deps.len; i++) {
+        const pkg *dep = p->deps.data[i];
+        size_t dep_idx = find_pkg(pkgs, dep);
+        if (dep_idx == SIZE_MAX) {
+            return (resolve_error){
+                .kind = RESOLVE_E_MISSING_DEP,
+                .pkg_name = p->name,
+                .dep_name = dep->name,
+            };
+        }
+
+        resolve_error err = visit(a, pkgs, dep_idx, state, out);
+        if (err.kind != RESOLVE_OK) return err;
+    }
+
+    out[idx].def = p;
+    out[idx].store_path = store_path_compute(a, p, pkgs, out);
+
+    state[idx] = VISITED;
+    return RESOLVE_OK_VAL;
+}
+
+/**
+ * resolve() - Topologically sort packages and compute their store paths.
+ * @a: Arena holding the resulting visit-state and resolved arrays.
+ * @cfg: System configuration providing the package list.
+ * @out: Filled with topologically-sorted &resolved entries on success.
+ *
+ * Rejects duplicate package names up front, then performs a DFS that
+ * detects cycles and missing deps. Each entry's store path depends on
+ * its deps' store paths, so children must be resolved first.
+ *
+ * Return: RESOLVE_OK_VAL on success, a tagged error otherwise.
+ */
+resolve_error resolve(
+    arena              *a,
+    const system_cfg   *cfg,
+    resolved_list      *out)
+{
+    for (size_t i = 0; i < cfg->pkgs.len; i++) {
+        for (size_t j = i + 1; j < cfg->pkgs.len; j++) {
+            if (strcmp(cfg->pkgs.data[i]->name, cfg->pkgs.data[j]->name) == 0) {
+                return (resolve_error){
+                    .kind = RESOLVE_E_DUPLICATE_NAME,
+                    .pkg_name = cfg->pkgs.data[i]->name,
+                };
+            }
+        }
+    }
+
+    visit_state *state = arena_alloc(a, cfg->pkgs.len * sizeof(visit_state),
+                                     alignof(visit_state));
+    resolved    *res   = arena_alloc(a, cfg->pkgs.len * sizeof(resolved),
+                                     alignof(resolved));
+    if (state == nullptr || res == nullptr) {
+        return (resolve_error){ .kind = RESOLVE_E_MISSING_DEP };
+    }
+
+    for (size_t i = 0; i < cfg->pkgs.len; i++) {
+        resolve_error err = visit(a, &cfg->pkgs, i, state, res);
+        if (err.kind != RESOLVE_OK) return err;
+    }
+
+    out->data = res;
+    out->len  = cfg->pkgs.len;
+    return RESOLVE_OK_VAL;
+}
diff --git a/src/resolve.h b/src/resolve.h
new file mode 100644
index 0000000..8f089c2
--- /dev/null
+++ b/src/resolve.h
@@ -0,0 +1,23 @@
+#ifndef NB_RESOLVE_H
+#define NB_RESOLVE_H
+
+#include "../include/nbos.h"
+#include "error.h"
+#include "arena.h"
+
+typedef struct {
+    const pkg  *def;
+    const char *store_path;
+} resolved;
+
+typedef struct {
+    const resolved *data;
+    size_t          len;
+} resolved_list;
+
+[[nodiscard]] resolve_error resolve(
+    arena              *a,
+    const system_cfg   *cfg,
+    resolved_list      *out);
+
+#endif
diff --git a/src/run.c b/src/run.c
new file mode 100644
index 0000000..e42e092
--- /dev/null
+++ b/src/run.c
@@ -0,0 +1,101 @@
+#define _GNU_SOURCE
+#include "run.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+/**
+ * run() - Fork+exec a command with stdout/stderr captured to a log.
+ * @cmd: Absolute path to the executable.
+ * @argv: NULL-terminated argv. argv[0] is conventionally @cmd.
+ * @envp: NULL-terminated envp. Pass {NULL} for an empty environment.
+ * @cwd: Working directory for the child, or NULL to inherit.
+ * @log_path: File path that captures the child's stdout and stderr.
+ *
+ * Stdin is redirected to /dev/null. The child is reaped before return.
+ *
+ * Return: RUN_OK_VAL on exit code 0; RUN_E_NONZERO with @exit_code and
+ * @log_path filled; RUN_E_SPAWN or RUN_E_IO with @errno_val filled.
+ */
+run_error run(
+    const char  *cmd,
+    char *const  argv[],
+    char *const  envp[],
+    const char  *cwd,
+    const char  *log_path)
+{
+    run_error err = RUN_OK_VAL;
+
+    int log_fd = open(log_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
+    if (log_fd < 0) {
+        err.kind = RUN_E_IO;
+        err.errno_val = errno;
+        return err;
+    }
+
+    pid_t pid = fork();
+    if (pid < 0) {
+        err.kind = RUN_E_SPAWN;
+        err.errno_val = errno;
+        close(log_fd);
+        return err;
+    }
+
+    if (pid == 0) {
+        if (cwd != nullptr && chdir(cwd) < 0) {
+            dprintf(log_fd, "run: chdir(%s) failed: %s\n", cwd, strerror(errno));
+            _exit(127);
+        }
+
+        if (dup2(log_fd, STDOUT_FILENO) < 0 || dup2(log_fd, STDERR_FILENO) < 0) {
+            _exit(127);
+        }
+        close(log_fd);
+
+        int devnull = open("/dev/null", O_RDONLY | O_CLOEXEC);
+        if (devnull >= 0) {
+            dup2(devnull, STDIN_FILENO);
+            close(devnull);
+        }
+
+        execve(cmd, argv, envp);
+        dprintf(STDERR_FILENO, "run: execve(%s) failed: %s\n", cmd, strerror(errno));
+        _exit(127);
+    }
+
+    close(log_fd);
+
+    int status = 0;
+    while (waitpid(pid, &status, 0) < 0) {
+        if (errno == EINTR) continue;
+        err.kind = RUN_E_IO;
+        err.errno_val = errno;
+        return err;
+    }
+
+    int code;
+    if (WIFEXITED(status)) {
+        code = WEXITSTATUS(status);
+    } else if (WIFSIGNALED(status)) {
+        code = 128 + WTERMSIG(status);
+    } else {
+        err.kind = RUN_E_IO;
+        err.errno_val = EINVAL;
+        return err;
+    }
+
+    if (code != 0) {
+        err.kind = RUN_E_NONZERO;
+        err.exit_code = code;
+        err.log_path = log_path;
+        return err;
+    }
+
+    return RUN_OK_VAL;
+}
diff --git a/src/run.h b/src/run.h
new file mode 100644
index 0000000..e84324a
--- /dev/null
+++ b/src/run.h
@@ -0,0 +1,13 @@
+#ifndef NB_RUN_H
+#define NB_RUN_H
+
+#include "error.h"
+
+[[nodiscard]] run_error run(
+    const char  *cmd,
+    char *const  argv[],
+    char *const  envp[],
+    const char  *cwd,
+    const char  *log_path);
+
+#endif
diff --git a/src/sandbox.c b/src/sandbox.c
new file mode 100644
index 0000000..8acbfdb
--- /dev/null
+++ b/src/sandbox.c
@@ -0,0 +1,46 @@
+#define _GNU_SOURCE
+#include "sandbox.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <sched.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+/**
+ * sandbox_setup() - Build a hermetic sandbox at @sandbox_root.
+ * @sandbox_root: Directory the sandbox is constructed under.
+ * @deps: Direct build deps of the package being built.
+ * @resolved_deps: Resolved entries paralleling the system's pkg list.
+ * @src_dir: Source tree to bind-mount as the build root inside.
+ *
+ * Read-only bind mounts each dep's store path; provides /dev/null,
+ * /dev/urandom, fresh procfs, a private tmpfs, and the source tree
+ * at /build. No network, no host /home, /etc, or /root.
+ *
+ * Return: 0 on success, errno value on failure.
+ */
+int sandbox_setup(
+    const char     *sandbox_root,
+    const pkg_refs *deps,
+    const resolved *resolved_deps,
+    const char     *src_dir)
+{
+    (void)sandbox_root;
+    (void)deps;
+    (void)resolved_deps;
+    (void)src_dir;
+    return ENOSYS;
+}
+
+/**
+ * sandbox_teardown() - Undo a sandbox built by sandbox_setup().
+ * @sandbox_root: Directory previously passed to sandbox_setup().
+ */
+void sandbox_teardown(const char *sandbox_root) {
+    if (sandbox_root == nullptr) return;
+}
diff --git a/src/sandbox.h b/src/sandbox.h
new file mode 100644
index 0000000..b2718ee
--- /dev/null
+++ b/src/sandbox.h
@@ -0,0 +1,15 @@
+#ifndef NB_SANDBOX_H
+#define NB_SANDBOX_H
+
+#include "../include/nbos.h"
+#include "resolve.h"
+
+[[nodiscard]] int sandbox_setup(
+    const char     *sandbox_root,
+    const pkg_refs *deps,
+    const resolved *resolved_deps,
+    const char     *src_dir);
+
+void sandbox_teardown(const char *sandbox_root);
+
+#endif
diff --git a/src/slice.h b/src/slice.h
new file mode 100644
index 0000000..60efeb8
--- /dev/null
+++ b/src/slice.h
@@ -0,0 +1,10 @@
+#ifndef NB_SLICE_H
+#define NB_SLICE_H
+
+#include <stddef.h>
+
+#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))
+#define SLICE_FROM_ARRAY(a) { .data = (a), .len = ARRAY_LEN(a) }
+#define SLICE_EMPTY { .data = nullptr, .len = 0 }
+
+#endif
diff --git a/src/store.c b/src/store.c
new file mode 100644
index 0000000..eeb4b9d
--- /dev/null
+++ b/src/store.c
@@ -0,0 +1,150 @@
+#define _GNU_SOURCE
+#include "store.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include "hash.h"
+
+static const char b32_alphabet[] = "0123456789abcdfghijklmnpqrsvwxyz";
+
+static void base32_encode(const uint8_t *in, size_t len, char *out) {
+    size_t bits = 0;
+    uint32_t buf = 0;
+    size_t out_idx = 0;
+
+    for (size_t i = 0; i < len; i++) {
+        buf = (buf << 8) | in[i];
+        bits += 8;
+        while (bits >= 5) {
+            bits -= 5;
+            out[out_idx++] = b32_alphabet[(buf >> bits) & 0x1f];
+        }
+    }
+    if (bits > 0) {
+        out[out_idx++] = b32_alphabet[(buf << (5 - bits)) & 0x1f];
+    }
+    out[out_idx] = '\0';
+}
+
+static const char *resolved_path_for(
+    const pkg          *target,
+    const pkg_refs     *all_pkgs,
+    const resolved     *resolved_pkgs)
+{
+    for (size_t i = 0; i < all_pkgs->len; i++) {
+        if (all_pkgs->data[i] == target) {
+            return resolved_pkgs[i].store_path;
+        }
+    }
+    return nullptr;
+}
+
+/**
+ * store_path_compute() - Compute the content-addressed store path for @p.
+ * @a: Arena that owns the returned string.
+ * @p: Package whose inputs determine the path.
+ * @all_pkgs: Full package list, used to look up dep store paths.
+ * @resolved_pkgs: Parallel array of resolved entries; deps must already
+ *                 have @store_path populated.
+ *
+ * Hashes a length-prefixed canonical encoding of the package inputs
+ * (name, version, src URL, source sha256, build flags, build system,
+ * resolved dep paths). The first 20 bytes of the digest are base32
+ * encoded; identical inputs always produce the same path.
+ *
+ * Return: "/nb/store/<hash>-<name>-<version>" owned by @a, or NULL if a
+ * dep's store path is not yet resolved.
+ */
+const char *store_path_compute(
+    arena              *a,
+    const pkg          *p,
+    const pkg_refs     *all_pkgs,
+    const resolved     *resolved_pkgs)
+{
+    sha256_ctx ctx;
+    sha256_init(&ctx);
+
+    #define HASH_FIELD(s) do { \
+        size_t l = strlen(s); \
+        sha256_update(&ctx, &l, sizeof(l)); \
+        sha256_update(&ctx, (s), l); \
+        sha256_update(&ctx, "\0", 1); \
+    } while (0)
+
+    HASH_FIELD(p->name);
+    HASH_FIELD(p->version);
+    HASH_FIELD(p->src);
+    HASH_FIELD(p->sha256);
+    HASH_FIELD(p->build_flags);
+
+    uint8_t bs = (uint8_t)p->build_sys;
+    sha256_update(&ctx, &bs, 1);
+    sha256_update(&ctx, "\0", 1);
+
+    for (size_t i = 0; i < p->deps.len; i++) {
+        const char *dep_path = resolved_path_for(p->deps.data[i],
+                                                  all_pkgs,
+                                                  resolved_pkgs);
+        if (dep_path == nullptr) return nullptr;
+        HASH_FIELD(dep_path);
+    }
+
+    #undef HASH_FIELD
+
+    uint8_t digest[32];
+    sha256_final(&ctx, digest);
+
+    char b32[33];
+    base32_encode(digest, 20, b32);
+
+    return arena_sprintf(a, "%s/%s-%s-%s",
+                         NB_STORE_ROOT, b32, p->name, p->version);
+}
+
+/**
+ * store_path_exists() - Check whether @store_path is a populated store entry.
+ * @store_path: Path to test.
+ *
+ * Return: true if @store_path is an existing directory.
+ */
+bool store_path_exists(const char *store_path) {
+    struct stat st;
+    if (stat(store_path, &st) < 0) return false;
+    return S_ISDIR(st.st_mode);
+}
+
+/**
+ * store_install() - Atomically move a build output into the store.
+ * @temp_path: Source directory produced by the build.
+ * @store_path: Destination inside the store.
+ *
+ * Return: 0 on success, errno value on failure.
+ */
+int store_install(const char *temp_path, const char *store_path) {
+    if (rename(temp_path, store_path) < 0) {
+        return errno;
+    }
+    return 0;
+}
+
+/**
+ * store_remove() - Recursively remove a store entry.
+ * @store_path: Path to remove.
+ *
+ * Return: 0 on success, errno value on failure.
+ */
+int store_remove(const char *store_path) {
+    char cmd[4096];
+    int n = snprintf(cmd, sizeof(cmd), "/bin/rm -rf '%s'", store_path);
+    if (n < 0 || (size_t)n >= sizeof(cmd)) return ENAMETOOLONG;
+    int rc = system(cmd);
+    return rc == 0 ? 0 : EIO;
+}
diff --git a/src/store.h b/src/store.h
new file mode 100644
index 0000000..3b396ec
--- /dev/null
+++ b/src/store.h
@@ -0,0 +1,22 @@
+#ifndef NB_STORE_H
+#define NB_STORE_H
+
+#include "../include/nbos.h"
+#include "arena.h"
+#include "resolve.h"
+
+#define NB_STORE_ROOT "/nb/store"
+
+[[nodiscard]] const char *store_path_compute(
+    arena              *a,
+    const pkg          *p,
+    const pkg_refs     *all_pkgs,
+    const resolved     *resolved_pkgs);
+
+[[nodiscard]] bool store_path_exists(const char *store_path);
+
+[[nodiscard]] int store_install(const char *temp_path, const char *store_path);
+
+[[nodiscard]] int store_remove(const char *store_path);
+
+#endif
diff --git a/src/validate.c b/src/validate.c
new file mode 100644
index 0000000..04548de
--- /dev/null
+++ b/src/validate.c
@@ -0,0 +1,95 @@
+#include "validate.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static bool pkg_in_list(const pkg *p, const pkg_refs *list) {
+    for (size_t i = 0; i < list->len; i++) {
+        if (list->data[i] == p) return true;
+    }
+    return false;
+}
+
+static bool is_known_bootloader(const char *name) {
+    return strcmp(name, "limine")   == 0
+        || strcmp(name, "grub")     == 0
+        || strcmp(name, "syslinux") == 0;
+}
+
+/**
+ * validate() - Run static checks on the system config.
+ * @cfg: Configuration to inspect.
+ *
+ * Covers what resolve() does not: hostname presence, bootloader
+ * recognition, kernel/shell pointers belong to the package list, and
+ * uniqueness of user and service names. Errors are printed to stderr.
+ *
+ * Return: true if every check passes.
+ */
+bool validate(const system_cfg *cfg) {
+    bool ok = true;
+
+    if (cfg->hostname == nullptr || cfg->hostname[0] == '\0') {
+        fprintf(stderr, "nb: hostname is empty\n");
+        ok = false;
+    }
+
+    if (!is_known_bootloader(cfg->boot.bootloader)) {
+        fprintf(stderr, "nb: unknown bootloader '%s'\n", cfg->boot.bootloader);
+        ok = false;
+    }
+
+    if (cfg->boot.kernel == nullptr) {
+        fprintf(stderr, "nb: boot.kernel is null\n");
+        ok = false;
+    } else if (!pkg_in_list(cfg->boot.kernel, &cfg->pkgs)) {
+        fprintf(stderr, "nb: boot.kernel '%s' is not in the package list\n",
+                cfg->boot.kernel->name);
+        ok = false;
+    }
+
+    for (size_t i = 0; i < cfg->users.len; i++) {
+        const user *u = &cfg->users.data[i];
+
+        if (u->name == nullptr || u->name[0] == '\0') {
+            fprintf(stderr, "nb: user[%zu] has empty name\n", i);
+            ok = false;
+            continue;
+        }
+
+        if (u->shell == nullptr) {
+            fprintf(stderr, "nb: user '%s' has null shell\n", u->name);
+            ok = false;
+        } else if (!pkg_in_list(u->shell, &cfg->pkgs)) {
+            fprintf(stderr, "nb: user '%s' shell '%s' is not in the package list\n",
+                    u->name, u->shell->name);
+            ok = false;
+        }
+
+        for (size_t j = i + 1; j < cfg->users.len; j++) {
+            if (strcmp(u->name, cfg->users.data[j].name) == 0) {
+                fprintf(stderr, "nb: duplicate user name '%s'\n", u->name);
+                ok = false;
+            }
+        }
+    }
+
+    for (size_t i = 0; i < cfg->services.len; i++) {
+        const service *s = &cfg->services.data[i];
+
+        if (s->name == nullptr || s->name[0] == '\0') {
+            fprintf(stderr, "nb: service[%zu] has empty name\n", i);
+            ok = false;
+            continue;
+        }
+
+        for (size_t j = i + 1; j < cfg->services.len; j++) {
+            if (strcmp(s->name, cfg->services.data[j].name) == 0) {
+                fprintf(stderr, "nb: duplicate service name '%s'\n", s->name);
+                ok = false;
+            }
+        }
+    }
+
+    return ok;
+}
diff --git a/src/validate.h b/src/validate.h
new file mode 100644
index 0000000..9176795
--- /dev/null
+++ b/src/validate.h
@@ -0,0 +1,8 @@
+#ifndef NB_VALIDATE_H
+#define NB_VALIDATE_H
+
+#include "../include/nbos.h"
+
+[[nodiscard]] bool validate(const system_cfg *cfg);
+
+#endif
diff --git a/tools/gen-registry.sh b/tools/gen-registry.sh
new file mode 100755
index 0000000..26b68fe
--- /dev/null
+++ b/tools/gen-registry.sh
@@ -0,0 +1,46 @@
+#!/bin/sh
+# Walks pkgs/ and emits all.h (declarations) and all.c (registry array).
+# Run via `make registry`.
+#
+# A recipe is a directory pkgs/<cat>/<name>/ containing <name>.c and <name>.h.
+# The .h must declare `extern const pkg pkgs_<name>;` and the .c must define it.
+
+set -eu
+
+PKGS_DIR="${PKGS_DIR:-pkgs}"
+MODE="${1:-h}"
+
+recipes=$(find "$PKGS_DIR" -mindepth 3 -maxdepth 3 -name '*.h' \
+            ! -name 'all.h' | sort)
+
+if [ "$MODE" = "h" ] || [ "$MODE" = "--h" ]; then
+    printf '/* AUTO-GENERATED. Do not edit. */\n'
+    printf '#ifndef PKGS_ALL_H\n'
+    printf '#define PKGS_ALL_H\n\n'
+    printf '#include "../include/nbos.h"\n\n'
+
+    for h in $recipes; do
+        name=$(basename "$h" .h)
+        printf 'extern const pkg pkgs_%s;\n' "$name"
+    done
+
+    printf '\n'
+    printf 'extern const pkg *const PKGS_REGISTRY[];\n'
+    printf 'extern const size_t   PKGS_REGISTRY_LEN;\n\n'
+    printf '#endif\n'
+elif [ "$MODE" = "c" ] || [ "$MODE" = "--c" ]; then
+    printf '/* AUTO-GENERATED. Do not edit. */\n'
+    printf '#include "all.h"\n\n'
+    printf 'const pkg *const PKGS_REGISTRY[] = {\n'
+
+    for h in $recipes; do
+        name=$(basename "$h" .h)
+        printf '    &pkgs_%s,\n' "$name"
+    done
+
+    printf '};\n\n'
+    printf 'const size_t PKGS_REGISTRY_LEN = sizeof(PKGS_REGISTRY) / sizeof(PKGS_REGISTRY[0]);\n'
+else
+    printf 'usage: %s [--h|--c]\n' "$0" >&2
+    exit 1
+fi