nbos
nbos
https://git.tonybtw.com/nbos.git
git://git.tonybtw.com/nbos.git
Initial commit.
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