commit f150a1ada14451dc7a989d3988055686c4ca413e
parent 273bce54805eb3c219b67f86b86d704deb574330
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 17 Jun 2026 12:59:49 -0700
selfdist: channel index lib (kit-release), env hooks (kit_home/fetch/rename), install-link extraction, test key
Diffstat:
21 files changed, 1019 insertions(+), 99 deletions(-)
diff --git a/driver/cmd/install.c b/driver/cmd/install.c
@@ -5,6 +5,7 @@
#include "driver.h"
#include "env.h"
+#include "install_links.h"
/* `kit install` — busybox-style toolchain installer. Populates a target
* directory with one entry per kit tool, each pointing at the running kit
@@ -54,77 +55,6 @@ void driver_help_install(void) {
" 0 success 1 one or more links failed 2 bad usage\n");
}
-/* Join DIR + "/" + NAME into a freshly allocated, NUL-terminated path. Returns
- * NULL on allocation failure; on success stores the allocation size in
- * *out_size for driver_free. */
-static char* install_join(DriverEnv* env, const char* dir, const char* name,
- size_t* out_size) {
- size_t dl = driver_strlen(dir);
- size_t nl = driver_strlen(name);
- int slash = dl > 0 && dir[dl - 1u] != '/';
- size_t size = dl + (slash ? 1u : 0u) + nl + 1u;
- size_t off;
- char* p = (char*)driver_alloc(env, size);
- if (!p) return NULL;
- driver_memcpy(p, dir, dl);
- off = dl;
- if (slash) p[off++] = '/';
- driver_memcpy(p + off, name, nl);
- off += nl;
- p[off] = '\0';
- *out_size = size;
- return p;
-}
-
-/* Create one link DIR/NAME -> self. Returns 0 on success, nonzero on failure
- * (already emitted a diagnostic). Honors dry-run (prints, no change), force
- * (replace existing), and verbose. */
-static int install_one(DriverEnv* env, const char* dir, const char* name,
- const char* self, int want_hard, int force, int dry,
- int verbose) {
- size_t path_size = 0;
- char* link_path = install_join(env, dir, name, &path_size);
- int rc = 0;
-
- if (!link_path) {
- driver_errf(INSTALL_TOOL, "out of memory building path for %s", name);
- return 1;
- }
-
- if (driver_path_lexists(link_path)) {
- if (!force) {
- driver_errf(INSTALL_TOOL, "%s already exists (use -f to overwrite)",
- link_path);
- rc = 1;
- goto done;
- }
- if (!dry && driver_remove_file(link_path) != 0) {
- driver_errf(INSTALL_TOOL, "cannot replace %s", link_path);
- rc = 1;
- goto done;
- }
- }
-
- if (dry) {
- driver_printf("%s -> %s (dry-run)\n", link_path, self);
- goto done;
- }
-
- rc = want_hard ? driver_create_hardlink(self, link_path)
- : driver_create_symlink(self, link_path);
- if (rc != 0) {
- driver_errf(INSTALL_TOOL, "failed to %s %s",
- want_hard ? "hard-link" : "symlink", link_path);
- rc = 1;
- goto done;
- }
- if (verbose) driver_printf("%s -> %s\n", link_path, self);
-
-done:
- driver_free(env, link_path, path_size);
- return rc;
-}
-
int driver_install(int argc, char** argv) {
DriverEnv env;
const char* dir = NULL;
@@ -212,24 +142,26 @@ int driver_install(int argc, char** argv) {
goto done;
}
- if (nexplicit > 0) {
- for (i = 0; i < nexplicit; ++i) {
- if (install_one(&env, dir, explicit_tools[i], self, want_hard, force, dry,
- verbose) != 0)
- ++failures;
- else
- ++done_count;
- }
- } else {
- unsigned n = driver_tool_count();
- unsigned j;
- for (j = 0; j < n; ++j) {
- if (!all && driver_tool_groups(j) == 0) continue;
- if (install_one(&env, dir, driver_tool_name(j), self, want_hard, force,
- dry, verbose) != 0)
- ++failures;
- else
- ++done_count;
+ {
+ DriverInstallLinkOpts opts;
+ opts.target_exe = self;
+ opts.tool_tag = INSTALL_TOOL;
+ opts.use_hardlink = want_hard;
+ opts.force = force;
+ opts.dry_run = dry;
+ opts.verbose = verbose;
+
+ if (nexplicit > 0) {
+ for (i = 0; i < nexplicit; ++i) {
+ if (driver_install_link_one(&env, dir, explicit_tools[i], &opts) != 0)
+ ++failures;
+ else
+ ++done_count;
+ }
+ } else {
+ unsigned mask = all ? DRIVER_GROUP_ALL
+ : (DRIVER_GROUP_TOOLCHAIN | DRIVER_GROUP_BYTEUTIL);
+ failures = driver_install_links(&env, dir, mask, &opts, &done_count);
}
}
diff --git a/driver/driver.h b/driver/driver.h
@@ -129,11 +129,34 @@ const char* kit_host_triple(void); /* e.g. "aarch64-macos" */
* so `kit --version` and `cc --version` / `ld --version` answer uniformly. */
void driver_print_version(const char* tool);
-/* Scan argv[1..argc-1] for a "--version" request, returning 1 on a hit. Stops
- * at "--" (like driver_argv_wants_help) so a flag forwarded to a JITed program
- * or emulated guest is never hijacked. */
+/* Returns 1 when the tool's first argument (argv[1]) is "--version". Only the
+ * first argument counts, so a sub-flag that legitimately spells --version
+ * (e.g. `pkg create --version <ver>`) is never hijacked; `cc --version` and
+ * `kit cc --version` both present --version as the dispatched tool's argv[1]. */
int driver_argv_wants_version(int argc, char** argv);
+/* ----------------------------------------------------------------------
+ * Self-distribution: `kit update` + the embedded release trust anchor
+ * (driver/cmd/update.c, driver/release_key.c). See doc/plan/SELFDIST.md.
+ * ---------------------------------------------------------------------- */
+int driver_update(int argc, char** argv);
+void driver_help_update(void);
+
+/* `kit update` verifies a downloaded release against this built-in set of
+ * minisign public keys; any one key verifying suffices, which supports
+ * overlap-window key rotation. The real release key's secret is held offline;
+ * the in-tree default is a clearly-labelled NON-RELEASE test key so the
+ * hermetic end-to-end test and the `make dist` fallback signer verify against a
+ * built-in anchor. A `kit update --key` override lets mirrors/forks supply
+ * their own. */
+typedef struct KitReleaseKey {
+ const char* label; /* human label */
+ const char* pubkey; /* minisign public-key file text (comment + base64) */
+} KitReleaseKey;
+
+/* Borrow the embedded release public-key set; *count receives its length. */
+const KitReleaseKey* driver_release_keys(unsigned* count);
+
/* Tool grouping, used by `install` to pick a default set without
* duplicating the tool list. The centralized table in main.c tags every
* row; the groups with a non-zero bit make up the default install set
diff --git a/driver/env.h b/driver/env.h
@@ -249,6 +249,28 @@ int driver_remove_file(const char* path);
* POSIX lstat / Windows GetFileAttributesW. */
int driver_path_lexists(const char* path);
+/* Rename/move `from` to `to`, replacing any existing entry at `to`. Within one
+ * filesystem this is atomic, which `kit update` relies on for the version dir
+ * move and the `current` pointer flip. Returns 0 on success, nonzero on
+ * failure. POSIX rename(2); Windows MoveFileExW(MOVEFILE_REPLACE_EXISTING). */
+int driver_rename(const char* from, const char* to);
+
+/* Resolve the single-root kit home directory into `buf` (capacity `cap`,
+ * NUL-terminated, no trailing slash): `$KIT_HOME`, else `$XDG_DATA_HOME/kit`,
+ * else `$HOME/.local/share/kit` on POSIX (`%LOCALAPPDATA%\kit` on Windows).
+ * Used by `kit update` for versions/, current, bin/, config/, cache/. Returns 0
+ * on success, nonzero when no home could be determined or it does not fit. */
+int driver_kit_home(char* buf, size_t cap);
+
+/* Fetch `url` to the local file `dest`, overwriting it. The transport is
+ * UNTRUSTED — the caller verifies a signature/content-id over the bytes — so
+ * this is a thin, narrow download primitive, not a general subprocess API:
+ * POSIX execs `curl -fsSL -o dest -- url` (falling back to `wget -O dest url`)
+ * via fork/exec with the URL passed as a distinct argv element (no shell); the
+ * Windows port uses curl.exe likewise. Returns 0 on success, nonzero on any
+ * failure (no fetcher found, network/HTTP error, write error). */
+int driver_fetch_url(const char* url, const char* dest);
+
/* Set a linked binary output's final mode according to the active umask.
* Returns 0 on success, nonzero on chmod failure. */
int driver_mark_executable_output(const char* path);
diff --git a/driver/env/posix.c b/driver/env/posix.c
@@ -756,6 +756,52 @@ int driver_path_lexists(const char* path) {
return lstat(path, &sb) == 0;
}
+int driver_rename(const char* from, const char* to) {
+ if (!from || !to) return 1;
+ return rename(from, to) == 0 ? 0 : 1; /* same-fs rename is atomic */
+}
+
+int driver_kit_home(char* buf, size_t cap) {
+ const char* v;
+ int n = -1;
+ size_t len;
+ if (!buf || cap == 0) return 1;
+ if ((v = getenv("KIT_HOME")) && *v)
+ n = snprintf(buf, cap, "%s", v);
+ else if ((v = getenv("XDG_DATA_HOME")) && *v)
+ n = snprintf(buf, cap, "%s/kit", v);
+ else if ((v = getenv("HOME")) && *v)
+ n = snprintf(buf, cap, "%s/.local/share/kit", v);
+ if (n < 0 || (size_t)n >= cap) return 1;
+ len = strlen(buf);
+ while (len > 1 && buf[len - 1] == '/') buf[--len] = '\0'; /* no trailing '/' */
+ return 0;
+}
+
+int driver_fetch_url(const char* url, const char* dest) {
+ pid_t pid;
+ int status;
+ if (!url || !dest) return 1;
+ pid = fork();
+ if (pid < 0) return 1;
+ if (pid == 0) {
+ /* Untrusted transport: the URL is a distinct argv element (no shell), so a
+ * hostile mirror URL cannot inject a command. curl first; if it is not
+ * installed (exec returns) fall back to wget; if neither, 127. */
+ execlp("curl", "curl", "-fsSL", "-o", dest, "--", url, (char*)NULL);
+ execlp("wget", "wget", "-q", "-O", dest, "--", url, (char*)NULL);
+ _exit(127);
+ }
+ do {
+ if (waitpid(pid, &status, 0) < 0) {
+ if (errno == EINTR) continue;
+ return 1;
+ }
+ break;
+ } while (1);
+ return (WIFEXITED(status) && WEXITSTATUS(status) == 0) ? 0 : 1;
+}
+
static int driver_walk_regular_files_at(DriverEnv* env, const char* dir,
const char* rel, DriverWalkFileFn cb,
void* user) {
diff --git a/driver/env/windows.c b/driver/env/windows.c
@@ -1060,6 +1060,61 @@ int driver_path_lexists(const char* path) {
return attr != INVALID_FILE_ATTRIBUTES;
}
+int driver_rename(const char* from, const char* to) {
+ wchar_t *wfrom, *wto;
+ BOOL ok;
+ if (!from || !to) return 1;
+ wfrom = widen(from);
+ wto = widen(to);
+ if (!wfrom || !wto) {
+ free(wfrom);
+ free(wto);
+ return 1;
+ }
+ ok = MoveFileExW(wfrom, wto, MOVEFILE_REPLACE_EXISTING);
+ free(wfrom);
+ free(wto);
+ return ok ? 0 : 1;
+}
+
+int driver_kit_home(char* buf, size_t cap) {
+ const char* v;
+ int n = -1;
+ size_t len;
+ if (!buf || cap == 0) return 1;
+ if ((v = getenv("KIT_HOME")) && *v)
+ n = snprintf(buf, cap, "%s", v);
+ else if ((v = getenv("XDG_DATA_HOME")) && *v)
+ n = snprintf(buf, cap, "%s/kit", v);
+ else if ((v = getenv("LOCALAPPDATA")) && *v)
+ n = snprintf(buf, cap, "%s\\kit", v);
+ if (n < 0 || (size_t)n >= cap) return 1;
+ len = strlen(buf);
+ while (len > 1 && (buf[len - 1] == '/' || buf[len - 1] == '\\'))
+ buf[--len] = '\0';
+ return 0;
+}
+
+int driver_fetch_url(const char* url, const char* dest) {
+ wchar_t *wurl, *wdest;
+ intptr_t rc;
+ if (!url || !dest) return 1;
+ wurl = widen(url);
+ wdest = widen(dest);
+ if (!wurl || !wdest) {
+ free(wurl);
+ free(wdest);
+ return 1;
+ }
+ /* curl.exe ships with Windows 10+; the URL is a distinct argv element (no
+ * shell), so a hostile mirror URL cannot inject a command. */
+ rc = _wspawnlp(_P_WAIT, L"curl", L"curl", L"-fsSL", L"-o", wdest, L"--", wurl,
+ (wchar_t*)NULL);
+ free(wurl);
+ free(wdest);
+ return rc == 0 ? 0 : 1;
+}
+
static int driver_walk_regular_files_at(DriverEnv* env, const char* dir,
const char* rel, DriverWalkFileFn cb,
void* user) {
diff --git a/driver/lib/install_links.c b/driver/lib/install_links.c
@@ -0,0 +1,94 @@
+#include "install_links.h"
+
+#include <stddef.h>
+
+/* Join DIR + "/" + NAME into a freshly allocated, NUL-terminated path. Returns
+ * NULL on allocation failure; on success stores the allocation size in
+ * *out_size for driver_free. */
+static char* install_join(DriverEnv* env, const char* dir, const char* name,
+ size_t* out_size) {
+ size_t dl = driver_strlen(dir);
+ size_t nl = driver_strlen(name);
+ int slash = dl > 0 && dir[dl - 1u] != '/';
+ size_t size = dl + (slash ? 1u : 0u) + nl + 1u;
+ size_t off;
+ char* p = (char*)driver_alloc(env, size);
+ if (!p) return NULL;
+ driver_memcpy(p, dir, dl);
+ off = dl;
+ if (slash) p[off++] = '/';
+ driver_memcpy(p + off, name, nl);
+ off += nl;
+ p[off] = '\0';
+ *out_size = size;
+ return p;
+}
+
+int driver_install_link_one(DriverEnv* env, const char* bindir,
+ const char* name,
+ const DriverInstallLinkOpts* opts) {
+ const char* tag = opts->tool_tag;
+ size_t path_size = 0;
+ char* link_path = install_join(env, bindir, name, &path_size);
+ int rc = 0;
+
+ if (!link_path) {
+ driver_errf(tag, "out of memory building path for %s", name);
+ return 1;
+ }
+
+ if (driver_path_lexists(link_path)) {
+ if (!opts->force) {
+ driver_errf(tag, "%s already exists (use -f to overwrite)", link_path);
+ rc = 1;
+ goto done;
+ }
+ if (!opts->dry_run && driver_remove_file(link_path) != 0) {
+ driver_errf(tag, "cannot replace %s", link_path);
+ rc = 1;
+ goto done;
+ }
+ }
+
+ if (opts->dry_run) {
+ driver_printf("%s -> %s (dry-run)\n", link_path, opts->target_exe);
+ goto done;
+ }
+
+ rc = opts->use_hardlink
+ ? driver_create_hardlink(opts->target_exe, link_path)
+ : driver_create_symlink(opts->target_exe, link_path);
+ if (rc != 0) {
+ driver_errf(tag, "failed to %s %s",
+ opts->use_hardlink ? "hard-link" : "symlink", link_path);
+ rc = 1;
+ goto done;
+ }
+ if (opts->verbose) driver_printf("%s -> %s\n", link_path, opts->target_exe);
+
+done:
+ driver_free(env, link_path, path_size);
+ return rc;
+}
+
+unsigned driver_install_links(DriverEnv* env, const char* bindir,
+ unsigned group_mask,
+ const DriverInstallLinkOpts* opts,
+ unsigned* out_done) {
+ unsigned n = driver_tool_count();
+ unsigned j;
+ unsigned done_count = 0, failures = 0;
+
+ for (j = 0; j < n; ++j) {
+ if (group_mask != DRIVER_GROUP_ALL &&
+ (driver_tool_groups(j) & group_mask) == 0)
+ continue;
+ if (driver_install_link_one(env, bindir, driver_tool_name(j), opts) != 0)
+ ++failures;
+ else
+ ++done_count;
+ }
+
+ if (out_done) *out_done = done_count;
+ return failures;
+}
diff --git a/driver/lib/install_links.h b/driver/lib/install_links.h
@@ -0,0 +1,62 @@
+#ifndef KIT_DRIVER_INSTALL_LINKS_H
+#define KIT_DRIVER_INSTALL_LINKS_H
+
+#include <stddef.h>
+
+#include "driver.h"
+#include "env.h"
+
+/* Shared tool-link laying for `kit install` (and, in future, `kit update`).
+ *
+ * "Installing" the kit toolchain means populating a directory with one entry
+ * per tool, each pointing at the running kit binary, so the tools can be
+ * invoked by their bare names (cc, ld, nm, ...). Entries are symlinks on POSIX
+ * and hard links on Windows; either can be forced. This module owns the
+ * mechanism (build the path, overwrite/replace policy, symlink-vs-hardlink,
+ * dry-run/verbose printing, diagnostic on failure); callers own the policy
+ * (which directory, which tools, how the link kind was chosen).
+ *
+ * Every diagnostic is emitted via driver_errf(tool_tag, ...) so the caller's
+ * tool name appears in error lines. */
+
+/* group_mask sentinel meaning "every tool compiled into this binary",
+ * regardless of its DriverToolGroup. Distinct from any real group-bit
+ * combination so it cannot collide with a DRIVER_GROUP_* union. */
+#define DRIVER_GROUP_ALL ((unsigned)~0u)
+
+/* Options shared by every link laid in one install/update run. `target_exe` is
+ * the link target (typically the running kit binary's absolute path).
+ * `use_hardlink` selects hard links (1) over symlinks (0). `force` replaces an
+ * existing entry; without it, an existing entry is an error. `dry_run` prints
+ * what would happen and changes nothing. `verbose` prints each link as it is
+ * created. `tool_tag` names the calling tool in diagnostics. */
+typedef struct DriverInstallLinkOpts {
+ const char* target_exe;
+ const char* tool_tag;
+ int use_hardlink;
+ int force;
+ int dry_run;
+ int verbose;
+} DriverInstallLinkOpts;
+
+/* Lay one link `bindir/name` -> opts->target_exe per `opts`. Returns 0 on
+ * success, nonzero on failure (after emitting a diagnostic via
+ * driver_errf(opts->tool_tag, ...)). Honors dry-run (prints, no change), force
+ * (replace an existing entry), and verbose. */
+int driver_install_link_one(DriverEnv* env, const char* bindir,
+ const char* name,
+ const DriverInstallLinkOpts* opts);
+
+/* Lay a link in `bindir` for every tool in the centralized table whose group
+ * set is selected by `group_mask`: a tool at index j is included iff
+ * `group_mask == DRIVER_GROUP_ALL` or `(driver_tool_groups(j) & group_mask)`
+ * is non-zero. Each link points at opts->target_exe. Returns 0 when every
+ * selected link succeeded, otherwise the number of links that failed (each
+ * already diagnosed). The count of links laid (the would-be / installed total)
+ * is stored in *out_done when out_done is non-NULL. */
+unsigned driver_install_links(DriverEnv* env, const char* bindir,
+ unsigned group_mask,
+ const DriverInstallLinkOpts* opts,
+ unsigned* out_done);
+
+#endif
diff --git a/driver/main.c b/driver/main.c
@@ -279,12 +279,12 @@ int driver_argv_wants_help(int argc, char** argv, int accept_short_h) {
}
int driver_argv_wants_version(int argc, char** argv) {
- int i;
- for (i = 1; i < argc; ++i) {
- if (driver_streq(argv[i], "--")) break; /* see driver_argv_wants_help */
- if (driver_streq(argv[i], "--version")) return 1;
- }
- return 0;
+ /* Only the FIRST argument a tool receives counts as a version query, so a
+ * sub-flag that legitimately spells --version (e.g. `pkg create --version
+ * 2026.6.0`, where --version is the package version) is never hijacked. The
+ * common forms `cc --version` and `kit cc --version` both arrive with
+ * --version as argv[1] of the dispatched tool. */
+ return argc >= 2 && driver_streq(argv[1], "--version");
}
void driver_print_version(const char* tool) {
diff --git a/driver/release_key.c b/driver/release_key.c
@@ -0,0 +1,26 @@
+/* Embedded release trust anchor(s) for `kit update`. See doc/plan/SELFDIST.md.
+ *
+ * `kit update` verifies a downloaded release against this built-in set of
+ * minisign public keys; any one key verifying suffices (overlap-window
+ * rotation). At real release time the production release public key is added
+ * here, with its secret held offline and never in the tree.
+ *
+ * The default entry below is the in-tree NON-RELEASE test key
+ * (test/dist/keys/nonrelease.pub, key id c1709dd2922282f6) — the same key the
+ * hermetic end-to-end test and the `make dist` fallback signer use, so a
+ * stock-built kit can verify a `make dist` artifact against a built-in anchor.
+ * It is NOT a release key and grants no trust over real distributions. */
+
+#include "driver.h"
+
+static const KitReleaseKey kit_release_keys[] = {
+ {"kit NON-RELEASE test key (c1709dd2922282f6)",
+ "untrusted comment: kit public key c1709dd2922282f6\n"
+ "RWTBcJ3SkiKC9rw+zEgXOtsk4rCI4x+PSf9VtBaP9XJFczB5RLtTOjLJ\n"},
+};
+
+const KitReleaseKey* driver_release_keys(unsigned* count) {
+ if (count)
+ *count = (unsigned)(sizeof kit_release_keys / sizeof kit_release_keys[0]);
+ return kit_release_keys;
+}
diff --git a/include/kit/package.h b/include/kit/package.h
@@ -138,4 +138,61 @@ KIT_API KitStatus kit_pkg_inspect(const KitContext* ctx,
KitPkgFormat format, int show_encoding,
KitWriter* out);
+/* ---- Channel index (kit-release) -----------------------------------------
+ *
+ * A small, signed, byte-stable text file describing the release artifacts of
+ * one channel (e.g. "stable"), in the same canonical-text + detached-minisign
+ * idiom as the package manifest. The library emits/parses the text and the
+ * CalVer comparison; trust, channel selection, host-triple matching, version
+ * policy, and signing/verification (via kit_pkg_* / minisign) stay with the
+ * caller. See doc/plan/SELFDIST.md.
+ *
+ * KitReleaseIndex is large (fixed-capacity host/url tables); allocate it on the
+ * heap, not the stack. Capacities are loud limits — emit/parse fail rather than
+ * silently truncate when exceeded. */
+
+#define KIT_RELEASE_CHANNEL_MAX 32u
+#define KIT_RELEASE_TARGET_MAX 64u
+#define KIT_RELEASE_MAX_HOSTS 24u
+#define KIT_RELEASE_MAX_URLS 6u
+#define KIT_RELEASE_URL_MAX 256u
+
+typedef struct KitReleaseHost {
+ char target[KIT_RELEASE_TARGET_MAX]; /* hosted triple, e.g. "aarch64-macos" */
+ uint8_t kpkg_id[KIT_CAS_HASH_LEN]; /* package-id of the .kpkg */
+ uint8_t targz_id[KIT_CAS_HASH_LEN]; /* package-id of the .tar.gz */
+ int has_kpkg; /* 1 if kpkg_id is present */
+ int has_targz; /* 1 if targz_id is present */
+ uint64_t size; /* .kpkg size in bytes; 0 = unspecified */
+ char urls[KIT_RELEASE_MAX_URLS][KIT_RELEASE_URL_MAX]; /* ordered mirror list */
+ unsigned n_urls;
+} KitReleaseHost;
+
+typedef struct KitReleaseIndex {
+ char channel[KIT_RELEASE_CHANNEL_MAX]; /* e.g. "stable" */
+ char version[KIT_PKG_VERSION_MAX]; /* CalVer "YYYY.MINOR.PATCH" */
+ KitReleaseHost hosts[KIT_RELEASE_MAX_HOSTS];
+ unsigned n_hosts;
+} KitReleaseIndex;
+
+/* Emit the canonical `kit-release 1` text for *index* to out. The output is
+ * byte-stable for a given index value (sorted [host] sections by target,
+ * fixed key order), so its blake2b id is reproducible. */
+KIT_API KitStatus kit_release_index_emit(const KitContext* ctx,
+ const KitReleaseIndex* index,
+ KitWriter* out);
+
+/* Parse `kit-release 1` text into *out* (zeroed first). Rejects an unknown
+ * version line, unknown keys/sections, malformed hex ids, non-canonical
+ * ordering, and any table overflow; emits detail through ctx->diag. */
+KIT_API KitStatus kit_release_index_parse(const KitContext* ctx,
+ const uint8_t* data, size_t len,
+ KitReleaseIndex* out);
+
+/* Compare two CalVer strings ("YYYY.MINOR.PATCH") numerically, field by field
+ * (YYYY, then MINOR, then PATCH). Stores the sign of a-b in *cmp (<0, 0, >0).
+ * Returns KIT_OK on success, or KIT_INVALID when either string is not
+ * well-formed CalVer (three non-negative decimal fields). */
+KIT_API KitStatus kit_calver_compare(const char* a, const char* b, int* cmp);
+
#endif
diff --git a/mk/driver_srcs.mk b/mk/driver_srcs.mk
@@ -84,6 +84,7 @@ DRIVER_SRCS += $(call need-any,STRIP OBJCOPY,driver/lib/objedit.c)
DRIVER_SRCS += $(call need-any,RUN,driver/lib/wasm_run.c)
DRIVER_SRCS += $(call need-any,CC CHECK BUILD_EXE BUILD_LIB BUILD_OBJ,driver/lib/link_inputs.c)
DRIVER_SRCS += $(call need-any,CAS PKG,driver/lib/dist_host.c)
+DRIVER_SRCS += $(call need-any,INSTALL UPDATE,driver/lib/install_links.c)
DRIVER_SRCS += $(call need-any,ADDR2LINE SYMBOLIZE,driver/lib/dwarfsym.c)
DRIVER_SRCS += $(call need-any,DBG RUN,driver/lib/backtrace.c)
diff --git a/mk/lib_srcs.mk b/mk/lib_srcs.mk
@@ -150,6 +150,7 @@ LIB_SRCS_DIST_CODEC = src/dist/deflate.c src/dist/lz4.c
LIB_SRCS_DIST_COMPRESS = src/dist/lz4frame.c
LIB_SRCS_DIST_PKG = src/dist/b64.c src/dist/ed25519.c src/dist/minisig.c \
src/dist/tar.c src/dist/kpkg.c src/dist/manifest.c \
+ src/dist/release.c \
src/dist/trust.c
LIB_SRCS_VENDOR_PKG = vendor/monocypher/monocypher-ed25519.c
LIB_SRCS_DEBUG := $(shell find src/debug -name '*.c' 2>/dev/null)
diff --git a/mk/test.mk b/mk/test.mk
@@ -81,6 +81,7 @@ TEST_TARGETS = \
test-gram \
test-driver-gram \
test-hash \
+ test-release-index \
test-driver-strip \
test-dwarf \
test-ecosystem \
@@ -547,6 +548,7 @@ CG_CONST_TEST_BIN = build/test/cg_const_test
STRENGTH_REDUCE_TEST_BIN = build/test/strength_reduce_test
TARGET_TEST_BIN = build/test/target_test
HASH_TEST_BIN = build/test/hash_test
+RELEASE_INDEX_TEST_BIN = build/test/release_index_test
PANIC_RECOVERY_TEST_BIN = build/test/panic_recovery_test
ABI_CLASSIFY_TEST_BIN = build/test/abi_classify_test
IR_RECORDER_TEST_BIN = build/test/ir_recorder_test
@@ -569,6 +571,9 @@ test-cg-api: $(TARGET_TEST_BIN) $(CG_API_TEST_BIN) $(CG_SWITCH_TEST_BIN) \
test-hash: $(HASH_TEST_BIN)
$(HASH_TEST_BIN)
+test-release-index: $(RELEASE_INDEX_TEST_BIN)
+ $(RELEASE_INDEX_TEST_BIN)
+
test-abi-classify: $(ABI_CLASSIFY_TEST_BIN)
$(ABI_CLASSIFY_TEST_BIN)
diff --git a/mk/test_unit.mk b/mk/test_unit.mk
@@ -32,7 +32,7 @@ UNIT_CFLAGS_INTERNAL = $(HOST_CFLAGS) -Iinclude -Isrc -Itest
UNIT_TESTS_PUBLIC := \
ar_test target_test arm32_target_features_test cg_api_test cg_switch_test \
cg_fp_cmp_test \
- cg_control_test cg_const_test hash_test \
+ cg_control_test cg_const_test hash_test release_index_test \
panic_recovery_test profile_test \
link_script_test \
rv64_jit_test rv32_jit_test aa64_inline_test rv64_inline_test x64_inline_test \
@@ -42,6 +42,7 @@ ar_test_SRC := test/ar/ar_test.c
target_test_SRC := test/api/target_test.c
arm32_target_features_test_SRC := test/api/arm32_target_features_test.c
hash_test_SRC := test/api/hash_test.c
+release_index_test_SRC := test/api/release_index_test.c
panic_recovery_test_SRC := test/api/panic_recovery_test.c
profile_test_SRC := test/api/profile_test.c
link_script_test_SRC := test/link/link_script_test.c
diff --git a/src/api/package.c b/src/api/package.c
@@ -23,6 +23,7 @@
#include "dist/lz4.h"
#include "dist/manifest.h"
#include "dist/minisig.h"
+#include "dist/release.h"
#include "dist/tar.h"
#include "dist/tree.h"
#include "dist/trust.h"
@@ -1686,6 +1687,40 @@ KitStatus kit_pkg_inspect(const KitContext* ctx, const uint8_t* pkg_data,
}
}
+KitStatus kit_release_index_emit(const KitContext* ctx,
+ const KitReleaseIndex* index, KitWriter* out) {
+ char err[128];
+ if (!ctx || !index || !out) return KIT_INVALID;
+ /* Validate first so an invalid index is reported with a clear reason; the
+ * emit re-validates internally but does not surface the message. */
+ if (dist_release_index_validate(index, err, sizeof err) != DIST_OK) {
+ kit_ctx_diagf(ctx, "%s", err);
+ return KIT_ERR;
+ }
+ if (dist_release_index_emit(index, out) != DIST_OK) {
+ if (kit_writer_status(out) != KIT_OK) return KIT_IO;
+ kit_ctx_diagf(ctx, "release index emit failed");
+ return KIT_ERR;
+ }
+ return kit_writer_status(out) == KIT_OK ? KIT_OK : KIT_IO;
+}
+
+KitStatus kit_release_index_parse(const KitContext* ctx, const uint8_t* data,
+ size_t len, KitReleaseIndex* out) {
+ char err[128];
+ if (!ctx || !data || !out) return KIT_INVALID;
+ if (dist_release_index_parse(data, len, out, err, sizeof err) != DIST_OK) {
+ kit_ctx_diagf(ctx, "%s", err);
+ return KIT_MALFORMED;
+ }
+ return KIT_OK;
+}
+
+KitStatus kit_calver_compare(const char* a, const char* b, int* cmp) {
+ if (!a || !b || !cmp) return KIT_INVALID;
+ return dist_calver_compare(a, b, cmp) == DIST_OK ? KIT_OK : KIT_INVALID;
+}
+
KitStatus kit_pkg_keygen(const KitContext* ctx, KitPkgRandomFn rng,
void* rng_user, KitWriter* pub_out, KitWriter* sec_out,
uint8_t out_keyid[KIT_PKG_KEYID_LEN]) {
diff --git a/src/dist/release.c b/src/dist/release.c
@@ -0,0 +1,352 @@
+#include "release.h"
+
+#include <stdio.h>
+#include <string.h>
+
+#include "dist_parse.h"
+
+/* The `kit-release 1` channel index: byte-stable canonical text in the package
+ * manifest idiom (src/dist/manifest.c). Top-level keys are emitted in the fixed
+ * order channel/version/hash; each [host] block is emitted sorted ascending by
+ * target, with keys target/kpkg/targz/size then the url mirror list in stored
+ * order. Operates directly on the public KitReleaseIndex. */
+
+_Static_assert(KIT_CAS_HASH_LEN == DIST_BLAKE2B_LEN, "release hash len");
+
+static int emit(KitWriter* out, const char* s) {
+ return kit_writer_write(out, s, strlen(s)) == KIT_OK ? DIST_OK : DIST_ERR;
+}
+
+static int emit_hex(KitWriter* out, const char* key,
+ const uint8_t h[DIST_BLAKE2B_LEN]) {
+ char hex[2 * DIST_BLAKE2B_LEN + 1];
+ dist_hex_encode(hex, h, DIST_BLAKE2B_LEN);
+ return dist_emit_kv(out, key, hex);
+}
+
+static int emit_u64(KitWriter* out, const char* key, uint64_t v) {
+ char num[24];
+ snprintf(num, sizeof num, "%llu", (unsigned long long)v);
+ return dist_emit_kv(out, key, num);
+}
+
+/* Per-host seen-key bits for the strict parser. */
+#define R1_F_TARGET 0x00000001u
+#define R1_F_KPKG 0x00000002u
+#define R1_F_TARGZ 0x00000004u
+#define R1_F_SIZE 0x00000008u
+
+/* Top-level seen-key bits. */
+#define R1_T_CHANNEL 0x00000001u
+#define R1_T_VERSION 0x00000002u
+#define R1_T_HASH 0x00000004u
+
+typedef enum { R1_SEC_TOP, R1_SEC_HOST } ReleaseSection;
+
+static int field_text_valid(const char* s, int required) {
+ if (required && !s[0]) return 0;
+ for (; *s; ++s) {
+ if (*s == '\n' || *s == '\r') return 0;
+ }
+ return 1;
+}
+
+/* A target triple must be non-empty, fit, and contain no whitespace. */
+static int target_text_valid(const char* s) {
+ if (!s[0]) return 0;
+ for (; *s; ++s) {
+ if (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') return 0;
+ }
+ return 1;
+}
+
+static int decode_id(uint8_t out[DIST_BLAKE2B_LEN], const char* val,
+ const char* err_msg, char* err, size_t errcap) {
+ if (strlen(val) != 2u * DIST_BLAKE2B_LEN ||
+ dist_hex_decode(out, val, DIST_BLAKE2B_LEN) != DIST_OK)
+ return dist_set_err(err, errcap, err_msg);
+ return DIST_OK;
+}
+
+static int seen_once(uint32_t* seen, uint32_t bit, const char* msg, char* err,
+ size_t errcap) {
+ if (*seen & bit) return dist_set_err(err, errcap, msg);
+ *seen |= bit;
+ return DIST_OK;
+}
+
+/* Parse "YYYY.MINOR.PATCH" into three uint64 fields. Each field must be a
+ * non-empty run of decimal digits with no overflow; exactly three fields,
+ * dot-separated, are required. */
+static int calver_split(const char* s, uint64_t out[3]) {
+ size_t field = 0;
+ if (!s) return DIST_ERR;
+ for (;;) {
+ uint64_t v = 0;
+ int digits = 0;
+ if (field >= 3) return DIST_ERR;
+ while (*s >= '0' && *s <= '9') {
+ unsigned d = (unsigned)(*s - '0');
+ if (v > (UINT64_MAX - (uint64_t)d) / 10u) return DIST_ERR;
+ v = v * 10u + (uint64_t)d;
+ ++digits;
+ ++s;
+ }
+ if (!digits) return DIST_ERR;
+ out[field++] = v;
+ if (*s == '\0') break;
+ if (*s != '.') return DIST_ERR;
+ ++s;
+ }
+ return field == 3 ? DIST_OK : DIST_ERR;
+}
+
+int dist_calver_compare(const char* a, const char* b, int* cmp) {
+ uint64_t fa[3], fb[3];
+ size_t i;
+ if (!cmp) return DIST_ERR;
+ if (calver_split(a, fa) != DIST_OK || calver_split(b, fb) != DIST_OK)
+ return DIST_ERR;
+ for (i = 0; i < 3; ++i) {
+ if (fa[i] != fb[i]) {
+ *cmp = fa[i] < fb[i] ? -1 : 1;
+ return DIST_OK;
+ }
+ }
+ *cmp = 0;
+ return DIST_OK;
+}
+
+int dist_release_index_validate(const KitReleaseIndex* idx, char* err,
+ size_t errcap) {
+ uint64_t ver[3];
+ unsigned i, j;
+
+ if (!field_text_valid(idx->channel, 1))
+ return dist_set_err(err, errcap, "bad or empty channel");
+ if (calver_split(idx->version, ver) != DIST_OK)
+ return dist_set_err(err, errcap, "bad CalVer version");
+ if (idx->n_hosts > KIT_RELEASE_MAX_HOSTS)
+ return dist_set_err(err, errcap, "too many hosts");
+
+ for (i = 0; i < idx->n_hosts; ++i) {
+ const KitReleaseHost* h = &idx->hosts[i];
+ if (!target_text_valid(h->target))
+ return dist_set_err(err, errcap, "bad or empty host target");
+ if (h->n_urls > KIT_RELEASE_MAX_URLS)
+ return dist_set_err(err, errcap, "too many urls");
+ for (j = 0; j < h->n_urls; ++j) {
+ if (!field_text_valid(h->urls[j], 1))
+ return dist_set_err(err, errcap, "bad or empty url");
+ }
+ /* Targets must be unique, but the in-memory array need not be pre-sorted:
+ * emit canonicalizes the [host] order (and parse enforces ascending in the
+ * file), so a caller may build hosts in any order. */
+ for (j = 0; j < i; ++j) {
+ if (strcmp(idx->hosts[j].target, h->target) == 0)
+ return dist_set_err(err, errcap, "duplicate host target");
+ }
+ }
+ return DIST_OK;
+}
+
+/* Stable ascending order over host targets without mutating the input: emit
+ * iterates an index permutation. The host count is small (<= caps), so an
+ * O(n^2) selection of the next-smallest target is fine and keeps the emit
+ * order canonical even if the caller built the index unsorted. */
+static unsigned next_host(const KitReleaseIndex* idx, int* used) {
+ unsigned i, best = idx->n_hosts;
+ for (i = 0; i < idx->n_hosts; ++i) {
+ if (used[i]) continue;
+ if (best == idx->n_hosts ||
+ strcmp(idx->hosts[i].target, idx->hosts[best].target) < 0)
+ best = i;
+ }
+ return best;
+}
+
+int dist_release_index_emit(const KitReleaseIndex* idx, KitWriter* out) {
+ int used[KIT_RELEASE_MAX_HOSTS];
+ char err[128];
+ unsigned k;
+
+ if (dist_release_index_validate(idx, err, sizeof err) != DIST_OK)
+ return DIST_ERR;
+
+ if (emit(out, DIST_RELEASE1_MAGIC "\n") != DIST_OK) return DIST_ERR;
+ if (dist_emit_kv(out, "channel", idx->channel) != DIST_OK) return DIST_ERR;
+ if (dist_emit_kv(out, "version", idx->version) != DIST_OK) return DIST_ERR;
+ if (dist_emit_kv(out, "hash", DIST_RELEASE1_HASH) != DIST_OK) return DIST_ERR;
+
+ memset(used, 0, sizeof used);
+ for (k = 0; k < idx->n_hosts; ++k) {
+ unsigned hi = next_host(idx, used);
+ const KitReleaseHost* h;
+ unsigned u;
+ used[hi] = 1;
+ h = &idx->hosts[hi];
+ if (emit(out, "\n[host]\n") != DIST_OK) return DIST_ERR;
+ if (dist_emit_kv(out, "target", h->target) != DIST_OK) return DIST_ERR;
+ if (h->has_kpkg && emit_hex(out, "kpkg", h->kpkg_id) != DIST_OK)
+ return DIST_ERR;
+ if (h->has_targz && emit_hex(out, "targz", h->targz_id) != DIST_OK)
+ return DIST_ERR;
+ if (h->size && emit_u64(out, "size", h->size) != DIST_OK) return DIST_ERR;
+ for (u = 0; u < h->n_urls; ++u) {
+ if (dist_emit_kv(out, "url", h->urls[u]) != DIST_OK) return DIST_ERR;
+ }
+ }
+ return DIST_OK;
+}
+
+int dist_release_index_parse(const uint8_t* data, size_t len,
+ KitReleaseIndex* out, char* err, size_t errcap) {
+ size_t pos = 0;
+ int first = 1;
+ ReleaseSection sec = R1_SEC_TOP;
+ uint32_t tseen = 0, hseen = 0;
+ KitReleaseHost* host = NULL;
+
+ memset(out, 0, sizeof *out);
+
+ while (pos < len) {
+ char buf[DIST_KV_LINE_MAX];
+ size_t end = pos;
+ size_t n, i;
+ char *t, *key, *val, *eq;
+
+ while (end < len && data[end] != '\n') ++end;
+ n = end - pos;
+ if (n >= sizeof buf) return dist_set_err(err, errcap, "line too long");
+ for (i = pos; i < end; ++i)
+ if (data[i] == 0)
+ return dist_set_err(err, errcap, "NUL byte in release index");
+ memcpy(buf, data + pos, n);
+ buf[n] = '\0';
+ pos = (end < len) ? end + 1 : end;
+ dist_trim_trail(buf);
+
+ if (first) {
+ first = 0;
+ if (strcmp(buf, DIST_RELEASE1_MAGIC) != 0)
+ return dist_set_err(err, errcap, "bad release index magic/version");
+ continue;
+ }
+
+ t = dist_trim_lead(buf);
+ if (*t == '\0' || *t == '#') continue;
+
+ if (*t == '[') {
+ if (strcmp(t, "[host]") != 0)
+ return dist_set_err(err, errcap, "unknown section");
+ if (sec == R1_SEC_TOP) {
+ if ((tseen & (R1_T_CHANNEL | R1_T_VERSION | R1_T_HASH)) !=
+ (R1_T_CHANNEL | R1_T_VERSION | R1_T_HASH))
+ return dist_set_err(err, errcap, "missing required top-level key");
+ } else if (!(hseen & R1_F_TARGET)) {
+ return dist_set_err(err, errcap, "[host] missing target");
+ }
+ if (out->n_hosts >= KIT_RELEASE_MAX_HOSTS)
+ return dist_set_err(err, errcap, "too many hosts");
+ sec = R1_SEC_HOST;
+ hseen = 0;
+ host = &out->hosts[out->n_hosts++];
+ continue;
+ }
+
+ eq = strchr(t, '=');
+ if (!eq) return dist_set_err(err, errcap, "expected key = value");
+ *eq = '\0';
+ key = t;
+ dist_trim_trail(key);
+ val = dist_trim_lead(eq + 1);
+
+ if (sec == R1_SEC_TOP) {
+ if (strcmp(key, "channel") == 0) {
+ if (seen_once(&tseen, R1_T_CHANNEL, "duplicate top-level key", err,
+ errcap) != DIST_OK)
+ return DIST_ERR;
+ if (!field_text_valid(val, 1))
+ return dist_set_err(err, errcap, "bad channel");
+ if (dist_copy_field(out->channel, sizeof out->channel, val, err,
+ errcap))
+ return DIST_ERR;
+ } else if (strcmp(key, "version") == 0) {
+ if (seen_once(&tseen, R1_T_VERSION, "duplicate top-level key", err,
+ errcap) != DIST_OK)
+ return DIST_ERR;
+ if (!field_text_valid(val, 1))
+ return dist_set_err(err, errcap, "bad version");
+ if (dist_copy_field(out->version, sizeof out->version, val, err,
+ errcap))
+ return DIST_ERR;
+ } else if (strcmp(key, "hash") == 0) {
+ if (seen_once(&tseen, R1_T_HASH, "duplicate top-level key", err,
+ errcap) != DIST_OK)
+ return DIST_ERR;
+ if (strcmp(val, DIST_RELEASE1_HASH) != 0)
+ return dist_set_err(err, errcap, "unsupported hash algorithm");
+ } else {
+ return dist_set_err(err, errcap, "unknown top-level key");
+ }
+ } else {
+ if (strcmp(key, "target") == 0) {
+ if (seen_once(&hseen, R1_F_TARGET, "duplicate [host] key", err,
+ errcap) != DIST_OK)
+ return DIST_ERR;
+ if (!target_text_valid(val))
+ return dist_set_err(err, errcap, "bad host target");
+ if (dist_copy_field(host->target, sizeof host->target, val, err,
+ errcap))
+ return DIST_ERR;
+ if (out->n_hosts >= 2u &&
+ strcmp(out->hosts[out->n_hosts - 2u].target, val) >= 0)
+ return dist_set_err(err, errcap,
+ "hosts not ascending/unique by target");
+ } else if (strcmp(key, "kpkg") == 0) {
+ if (seen_once(&hseen, R1_F_KPKG, "duplicate [host] key", err, errcap) !=
+ DIST_OK)
+ return DIST_ERR;
+ if (decode_id(host->kpkg_id, val, "bad kpkg id", err, errcap) !=
+ DIST_OK)
+ return DIST_ERR;
+ host->has_kpkg = 1;
+ } else if (strcmp(key, "targz") == 0) {
+ if (seen_once(&hseen, R1_F_TARGZ, "duplicate [host] key", err, errcap) !=
+ DIST_OK)
+ return DIST_ERR;
+ if (decode_id(host->targz_id, val, "bad targz id", err, errcap) !=
+ DIST_OK)
+ return DIST_ERR;
+ host->has_targz = 1;
+ } else if (strcmp(key, "size") == 0) {
+ if (seen_once(&hseen, R1_F_SIZE, "duplicate [host] key", err, errcap) !=
+ DIST_OK)
+ return DIST_ERR;
+ if (dist_parse_u64(val, &host->size) != DIST_OK)
+ return dist_set_err(err, errcap, "bad size");
+ } else if (strcmp(key, "url") == 0) {
+ if (host->n_urls >= KIT_RELEASE_MAX_URLS)
+ return dist_set_err(err, errcap, "too many urls");
+ if (!field_text_valid(val, 1))
+ return dist_set_err(err, errcap, "bad url");
+ if (dist_copy_field(host->urls[host->n_urls], sizeof host->urls[0], val,
+ err, errcap))
+ return DIST_ERR;
+ ++host->n_urls;
+ } else {
+ return dist_set_err(err, errcap, "unknown [host] key");
+ }
+ }
+ }
+
+ if (first) return dist_set_err(err, errcap, "bad release index magic/version");
+ if (sec == R1_SEC_TOP) {
+ if ((tseen & (R1_T_CHANNEL | R1_T_VERSION | R1_T_HASH)) !=
+ (R1_T_CHANNEL | R1_T_VERSION | R1_T_HASH))
+ return dist_set_err(err, errcap, "missing required top-level key");
+ } else if (!(hseen & R1_F_TARGET)) {
+ return dist_set_err(err, errcap, "[host] missing target");
+ }
+ return dist_release_index_validate(out, err, errcap);
+}
diff --git a/src/dist/release.h b/src/dist/release.h
@@ -0,0 +1,42 @@
+#ifndef KIT_DIST_RELEASE_H
+#define KIT_DIST_RELEASE_H
+
+#include <kit/core.h>
+#include <kit/package.h> /* KitReleaseIndex / KitReleaseHost */
+#include <stddef.h>
+#include <stdint.h>
+
+#include "dist.h"
+
+/* The signed channel index ("kit-release"). Byte-stable canonical text in the
+ * same idiom as the package manifest (src/dist/manifest.{h,c}); the detached
+ * minisign signature and trust policy live in the caller. Operates directly on
+ * the public KitReleaseIndex so the driver (which sees only include/kit/) and
+ * the library share one representation. See doc/plan/SELFDIST.md. */
+
+#define DIST_RELEASE1_MAGIC "kit-release 1"
+#define DIST_RELEASE1_HASH "blake2b-256"
+
+/* Emit the canonical `kit-release 1` text for *idx* to *out*. Returns DIST_OK,
+ * or DIST_ERR on a writer failure or an invalid index (validated first). */
+int dist_release_index_emit(const KitReleaseIndex* idx, KitWriter* out);
+
+/* Parse `kit-release 1` text into *out* (zeroed first). Returns DIST_OK, or
+ * DIST_ERR with a human-readable reason in err[0..errcap) (when errcap > 0) on
+ * a malformed magic/version, unknown key/section, bad hex id, non-canonical
+ * [host] ordering (must be ascending by target), or any table overflow. */
+int dist_release_index_parse(const uint8_t* data, size_t len,
+ KitReleaseIndex* out, char* err, size_t errcap);
+
+/* Structural validation shared by emit and parse: non-empty channel, valid
+ * CalVer version, each host has a valid target triple, ascending unique
+ * targets, url/host counts within caps. Returns DIST_OK or DIST_ERR (+err). */
+int dist_release_index_validate(const KitReleaseIndex* idx, char* err,
+ size_t errcap);
+
+/* Compare two CalVer strings ("YYYY.MINOR.PATCH") numerically, field by field.
+ * Stores the sign of a-b in *cmp. Returns DIST_OK, or DIST_ERR when either
+ * string is not three non-negative decimal fields. */
+int dist_calver_compare(const char* a, const char* b, int* cmp);
+
+#endif
diff --git a/test/api/release_index_test.c b/test/api/release_index_test.c
@@ -0,0 +1,148 @@
+/* release_index_test — public <kit/package.h> channel-index surface:
+ *
+ * - Build a KitReleaseIndex (two hosts, inserted out of target order, with
+ * both ids, a size, and a mirror list), emit it to a memory KitWriter, and
+ * assert the EXACT canonical bytes (byte-stability: hosts sorted ascending
+ * by target, fixed key order).
+ * - Parse those bytes back and assert full round-trip equality.
+ * - kit_calver_compare: numeric (not lexical) ordering and malformed input.
+ *
+ * Run by: make test-release-index
+ */
+
+#include <kit/core.h>
+#include <kit/package.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+
+#include "lib/kit_unit.h"
+
+static KitUnit g_u;
+#define EXPECT(c, ...) CU_EXPECT(&g_u, c, __VA_ARGS__)
+
+/* aarch64-macos sorts before x86_64-linux-gnu, so it is emitted first even
+ * though we register it second. kpkg id = bytes 0x00..0x1f, targz = 0x20..0x3f.
+ */
+static const char* const GOLDEN =
+ "kit-release 1\n"
+ "channel = stable\n"
+ "version = 2026.6.0\n"
+ "hash = blake2b-256\n"
+ "\n"
+ "[host]\n"
+ "target = aarch64-macos\n"
+ "kpkg = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\n"
+ "targz = 202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f\n"
+ "size = 31457280\n"
+ "url = https://example/a.kpkg\n"
+ "url = https://dl.example/a.kpkg\n"
+ "url = https://mirror.example/a.kpkg\n"
+ "\n"
+ "[host]\n"
+ "target = x86_64-linux-gnu\n"
+ "kpkg = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f\n"
+ "url = https://example/b.kpkg\n";
+
+static void fill_index(KitReleaseIndex* idx) {
+ KitReleaseHost* a;
+ KitReleaseHost* b;
+ unsigned i;
+ memset(idx, 0, sizeof *idx);
+ snprintf(idx->channel, sizeof idx->channel, "%s", "stable");
+ snprintf(idx->version, sizeof idx->version, "%s", "2026.6.0");
+ idx->n_hosts = 2;
+
+ /* Register x86_64-linux-gnu FIRST to prove emit re-sorts ascending. */
+ b = &idx->hosts[0];
+ snprintf(b->target, sizeof b->target, "%s", "x86_64-linux-gnu");
+ for (i = 0; i < KIT_CAS_HASH_LEN; ++i) b->kpkg_id[i] = (uint8_t)i;
+ b->has_kpkg = 1;
+ b->has_targz = 0;
+ b->size = 0; /* omitted */
+ snprintf(b->urls[0], sizeof b->urls[0], "%s", "https://example/b.kpkg");
+ b->n_urls = 1;
+
+ a = &idx->hosts[1];
+ snprintf(a->target, sizeof a->target, "%s", "aarch64-macos");
+ for (i = 0; i < KIT_CAS_HASH_LEN; ++i) a->kpkg_id[i] = (uint8_t)i;
+ for (i = 0; i < KIT_CAS_HASH_LEN; ++i) a->targz_id[i] = (uint8_t)(i + 32u);
+ a->has_kpkg = 1;
+ a->has_targz = 1;
+ a->size = 31457280u;
+ snprintf(a->urls[0], sizeof a->urls[0], "%s", "https://example/a.kpkg");
+ snprintf(a->urls[1], sizeof a->urls[1], "%s", "https://dl.example/a.kpkg");
+ snprintf(a->urls[2], sizeof a->urls[2], "%s", "https://mirror.example/a.kpkg");
+ a->n_urls = 3;
+}
+
+static int host_eq(const KitReleaseHost* x, const KitReleaseHost* y) {
+ unsigned i;
+ if (strcmp(x->target, y->target) != 0) return 0;
+ if (x->has_kpkg != y->has_kpkg || x->has_targz != y->has_targz) return 0;
+ if (x->size != y->size || x->n_urls != y->n_urls) return 0;
+ if (x->has_kpkg && memcmp(x->kpkg_id, y->kpkg_id, KIT_CAS_HASH_LEN) != 0)
+ return 0;
+ if (x->has_targz && memcmp(x->targz_id, y->targz_id, KIT_CAS_HASH_LEN) != 0)
+ return 0;
+ for (i = 0; i < x->n_urls; ++i)
+ if (strcmp(x->urls[i], y->urls[i]) != 0) return 0;
+ return 1;
+}
+
+/* The parsed index is canonical (ascending by target), so compare it to the
+ * sorted expectation: aarch64-macos first, then x86_64-linux-gnu. */
+static void check_roundtrip(const uint8_t* bytes, size_t len) {
+ static KitReleaseIndex want; /* large; keep off the stack */
+ static KitReleaseIndex got;
+ fill_index(&want);
+ EXPECT(kit_release_index_parse(&g_u.ctx, bytes, len, &got) == KIT_OK,
+ "parse golden bytes OK");
+ EXPECT(strcmp(got.channel, want.channel) == 0, "channel round-trips");
+ EXPECT(strcmp(got.version, want.version) == 0, "version round-trips");
+ EXPECT(got.n_hosts == want.n_hosts, "n_hosts round-trips (%u)", got.n_hosts);
+ if (got.n_hosts == 2) {
+ EXPECT(host_eq(&got.hosts[0], &want.hosts[1]), "host[0] == aarch64-macos");
+ EXPECT(host_eq(&got.hosts[1], &want.hosts[0]),
+ "host[1] == x86_64-linux-gnu");
+ }
+}
+
+static void check_emit_parse(void) {
+ static KitReleaseIndex idx; /* large; keep off the stack */
+ KitWriter* w = NULL;
+ const uint8_t* bytes;
+ size_t len, want_len;
+ fill_index(&idx);
+ EXPECT(kit_writer_mem(&g_u.heap, &w) == KIT_OK, "writer_mem OK");
+ EXPECT(kit_release_index_emit(&g_u.ctx, &idx, w) == KIT_OK, "emit OK");
+ bytes = kit_writer_mem_bytes(w, &len);
+ want_len = strlen(GOLDEN);
+ EXPECT(len == want_len, "emitted length %zu == golden %zu", len, want_len);
+ EXPECT(len == want_len && memcmp(bytes, GOLDEN, len) == 0,
+ "emitted bytes match golden exactly");
+ check_roundtrip(bytes, len);
+ kit_writer_close(w);
+}
+
+static void check_calver(void) {
+ int cmp = 99;
+ EXPECT(kit_calver_compare("2026.6.0", "2026.6.1", &cmp) == KIT_OK && cmp < 0,
+ "2026.6.0 < 2026.6.1");
+ EXPECT(kit_calver_compare("2026.10.0", "2026.9.0", &cmp) == KIT_OK && cmp > 0,
+ "2026.10.0 > 2026.9.0 (numeric, not lexical)");
+ EXPECT(kit_calver_compare("2026.6.0", "2026.6.0", &cmp) == KIT_OK && cmp == 0,
+ "2026.6.0 == 2026.6.0");
+ EXPECT(kit_calver_compare("2026.6", "2026.6.0", &cmp) == KIT_INVALID,
+ "too few fields -> KIT_INVALID");
+ EXPECT(kit_calver_compare("2026.x.0", "2026.6.0", &cmp) == KIT_INVALID,
+ "non-numeric field -> KIT_INVALID");
+}
+
+int main(void) {
+ kit_unit_init(&g_u);
+ check_emit_parse();
+ check_calver();
+ kit_unit_summary(&g_u, "release_index_test");
+ return kit_unit_status(&g_u);
+}
diff --git a/test/dist/keys/README.md b/test/dist/keys/README.md
@@ -0,0 +1,14 @@
+# NON-RELEASE test keys — DO NOT TRUST
+
+`nonrelease.key` / `nonrelease.pub` are a **passwordless, checked-in, NON-RELEASE**
+minisign (Ed25519) keypair, key id `c1709dd2922282f6`, used only by:
+
+- the hermetic self-distribution end-to-end test (`test/dist/run.sh`), and
+- the `make dist` fallback signer when `KIT_SIGN_KEY` is unset.
+
+Its public key is embedded in `driver/release_key.c` as a built-in `kit update`
+trust anchor **so the test and `make dist` artifacts verify against a built-in
+key**. It is published in the open here on purpose; it confers **no** trust over
+real kit distributions. The production release keypair is generated offline, its
+secret never enters the tree, and its public key replaces/augments the embedded
+set at release time (see `doc/plan/SELFDIST.md`).
diff --git a/test/dist/keys/nonrelease.key b/test/dist/keys/nonrelease.key
@@ -0,0 +1,2 @@
+untrusted comment: kit secret key c1709dd2922282f6
+RWQAAEIyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwXCd0pIigvbXcrDcsFwhxEa3USWokj5FD+ZjAwSuMuv3BqxpDsb+gbw+zEgXOtsk4rCI4x+PSf9VtBaP9XJFczB5RLtTOjLJ5D19jc6Et+kZL5ScC9tOKWIXoUCB8mtnRtgRnIg9wkk=
diff --git a/test/dist/keys/nonrelease.pub b/test/dist/keys/nonrelease.pub
@@ -0,0 +1,2 @@
+untrusted comment: kit public key c1709dd2922282f6
+RWTBcJ3SkiKC9rw+zEgXOtsk4rCI4x+PSf9VtBaP9XJFczB5RLtTOjLJ