Files
libglacier-ng/src/pkg.c
T
2026-10-14 00:50:56 -04:00

707 lines
21 KiB
C

#define _POSIX_C_SOURCE 200809L
#define _XOPEN_SOURCE 700
#include <errno.h>
#include <libconfig.h>
#include <limits.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "common.h"
#include "pkg.h"
/* "MAJ.MIN.PAT[-TAG]". Each number is read with strtol and clamped to
* 0..INT_MAX (a manifest is untrusted: sscanf's %d overflowing is
* undefined); anything missing or unparseable is 0. */
semver_t
gl_parse_semver(const char *str)
{
semver_t v = {0, 0, 0, NULL};
if (!str) { return v; }
char buffer[64];
snprintf(buffer, sizeof(buffer), "%s", str);
char *dash = strchr(buffer, '-');
if (dash) {
*dash = '\0';
v.tag = strdup(dash + 1);
}
int *parts[] = { &v.maj, &v.min, &v.pat };
const char *p = buffer;
for (int i = 0; i < 3 && *p; i++) {
char *end;
errno = 0;
long n = strtol(p, &end, 10);
if (end == p) { break; }
*parts[i] = (errno || n < 0) ? 0 : n > INT_MAX ? INT_MAX : (int)n;
if (*end != '.') { break; }
p = end + 1;
}
return v;
}
void
gl_free_semver(semver_t *v)
{
if (v && v->tag) { free (v->tag); };
}
/*
* gl_valid_pkg_ident
* DESCRIPTION: checks that a package name or repo name is safe to use as a
* single path component: non-empty, only [A-Za-z0-9._-], and not
* "." or "..". Both come straight out of an untrusted manifest (or
* off a command line) and get spliced into store paths, so
* anything else could escape ctx->store_path.
* PARAMETERS:
* ~ const char *s (the identifier to check; NULL is invalid)
* RETURNS:
* ~ true if safe, false otherwise
*/
bool
gl_valid_pkg_ident(const char *s)
{
if (!s || s[0] == '\0') { return false; }
if (strcmp(s, ".") == 0 || strcmp(s, "..") == 0) { return false; }
for (const char *p = s; *p; p++) {
char c = *p;
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-')) {
return false;
}
}
return true;
}
/* A manifest comes out of an untrusted archive. libconfig honours
* `@include "FILE"` at the start of a line, which would have it read any
* file (as root, under syspkg): such a manifest is refused. */
static char *
read_manifest_text(const char *filename)
{
FILE *f = fopen(filename, "rb");
if (!f) { return NULL; }
enum { MAX_MANIFEST = 1024 * 1024 };
char *buf = malloc(MAX_MANIFEST + 1);
size_t n = buf ? fread(buf, 1, MAX_MANIFEST + 1, f) : 0;
bool bad = !buf || ferror(f) || n > MAX_MANIFEST || memchr(buf, '\0', n);
fclose(f);
if (bad) {
free(buf);
return NULL;
}
buf[n] = '\0';
for (const char *line = buf; line; line = strchr(line, '\n')) {
if (*line == '\n') { line++; }
while (*line == ' ' || *line == '\t') { line++; }
if (strncmp(line, "@include", 8) == 0) {
free(buf);
return NULL;
}
}
return buf;
}
/* PKG_DEPS / PKG_CONFS: a list of { PKG_NAME; PKG_VER; }, each name a
* gl_valid_pkg_ident, into a NULL-name-terminated array of
* (name, version) pairs, names and vers parallel. false if malformed. */
static bool
manifest_relations(config_t *cfg, const char *key, char ***names, semver_t **vers)
{
*names = NULL;
*vers = NULL;
config_setting_t *l = config_lookup(cfg, key);
if (!l) { return true; }
if (!config_setting_is_list(l)) { return false; }
int count = config_setting_length(l);
*names = calloc((size_t)count + 1, sizeof(**names));
*vers = calloc((size_t)count + 1, sizeof(**vers));
if (!*names || !*vers) { return false; }
for (int i = 0; i < count; i++) {
config_setting_t *d = config_setting_get_elem(l, (unsigned)i);
const char *name = NULL, *ver = NULL;
config_setting_lookup_string(d, "PKG_NAME", &name);
config_setting_lookup_string(d, "PKG_VER", &ver);
if (!gl_valid_pkg_ident(name) || !((*names)[i] = strdup(name))) {
return false;
}
(*vers)[i] = gl_parse_semver(ver);
}
return true;
}
static void
free_relations(char **names, semver_t *vers)
{
for (size_t i = 0; names && names[i]; i++) {
free(names[i]);
gl_free_semver(&vers[i]);
}
free(names);
free(vers);
}
int
gl_gpm2gpkg(const char *filename, gl_pkg_t *pkg)
{
char *text = read_manifest_text(filename);
if (!text) { return ENOENT; }
config_t cfg;
config_init(&cfg);
int parsed = config_read_string(&cfg, text);
free(text);
if (!parsed) {
config_destroy(&cfg);
return ENOENT;
}
/* Validate before anything is strdup'd, so a rejected manifest leaves
* pkg untouched and the caller has nothing to free. */
const char *tmp_str, *name = NULL, *repo = NULL;
char **dnames, **cnames;
semver_t *dvers, *cvers;
config_lookup_string(&cfg, "PKG_NAME", &name);
config_lookup_string(&cfg, "PKG_REPO", &repo);
bool ok = gl_valid_pkg_ident(name) && gl_valid_pkg_ident(repo);
bool rels = manifest_relations(&cfg, "PKG_DEPS", &dnames, &dvers);
rels = manifest_relations(&cfg, "PKG_CONFS", &cnames, &cvers) && rels;
if (!ok || !rels) {
free_relations(dnames, dvers);
free_relations(cnames, cvers);
config_destroy(&cfg);
return EINVAL;
}
pkg->pkg_name = strdup(name);
pkg->pkg_repo = strdup(repo);
if (config_lookup_string(&cfg, "PKG_DESC", &tmp_str)) { pkg->pkg_desc = strdup(tmp_str); }
if (config_lookup_string(&cfg, "PKG_MAINT", &tmp_str)) { pkg->pkg_maint = strdup(tmp_str); }
if (config_lookup_string(&cfg, "PKG_COPYRIGHT", &tmp_str)) { pkg->pkg_copyright = strdup(tmp_str); }
if (config_lookup_string(&cfg, "PKG_VER", &tmp_str)) { pkg->pkg_ver = gl_parse_semver(tmp_str); }
if (dnames) {
size_t n = 0;
while (dnames[n]) { n++; }
pkg->pkg_dependencies = calloc(n + 1, sizeof(dep_t));
for (size_t i = 0; pkg->pkg_dependencies && i < n; i++) {
pkg->pkg_dependencies[i].dep_name = dnames[i];
pkg->pkg_dependencies[i].dep_ver = dvers[i];
dnames[i] = NULL;
}
}
if (cnames) {
size_t n = 0;
while (cnames[n]) { n++; }
pkg->pkg_conflicts = calloc(n + 1, sizeof(conf_t));
for (size_t i = 0; pkg->pkg_conflicts && i < n; i++) {
pkg->pkg_conflicts[i].conf_name = cnames[i];
pkg->pkg_conflicts[i].conf_ver = cvers[i];
cnames[i] = NULL;
}
}
free_relations(dnames, dvers);
free_relations(cnames, cvers);
config_destroy(&cfg);
if (!pkg->pkg_name || !pkg->pkg_repo || (dnames && !pkg->pkg_dependencies) ||
(cnames && !pkg->pkg_conflicts)) {
gl_free_pkg(pkg);
memset(pkg, 0, sizeof(*pkg));
return ENOMEM;
}
return 0;
}
void
gl_free_pkg(gl_pkg_t *pkg)
{
if (!pkg) return;
free(pkg->pkg_name);
free(pkg->pkg_desc);
free(pkg->pkg_maint);
free(pkg->pkg_copyright);
free(pkg->pkg_repo);
gl_free_semver(&pkg->pkg_ver);
if (pkg->pkg_dependencies) {
for (dep_t *d = pkg->pkg_dependencies; d->dep_name; d++) {
free(d->dep_name);
gl_free_semver(&d->dep_ver);
}
free(pkg->pkg_dependencies);
}
if (pkg->pkg_conflicts) {
for (conf_t *c = pkg->pkg_conflicts; c->conf_name; c++) {
free(c->conf_name);
gl_free_semver(&c->conf_ver);
}
free(pkg->pkg_conflicts);
}
}
/*
* Read integer `key` from a libconfig file (glacier.conf). Distinguishes
* the failures a caller has to report differently:
* GL_CONF_OK *out set
* GL_CONF_ERR_READ file missing, unreadable, or not valid libconfig
* GL_CONF_ERR_MISSING file fine, key absent
* GL_CONF_ERR_TYPE key present but not an integer
* There are deliberately no defaults here: a setting the caller needs and
* the file doesn't have is an error, not a silent fallback (CLAUDE.md bug
* 10 is what a plausible default hides).
*/
gl_conf_status_t
gl_conf_get_int(const char *path, const char *key, int *out)
{
config_t cfg;
config_init(&cfg);
if (!config_read_file(&cfg, path)) {
config_destroy(&cfg);
return GL_CONF_ERR_READ;
}
config_setting_t *setting = config_lookup(&cfg, key);
if (!setting) {
config_destroy(&cfg);
return GL_CONF_ERR_MISSING;
}
if (config_setting_type(setting) != CONFIG_TYPE_INT) {
config_destroy(&cfg);
return GL_CONF_ERR_TYPE;
}
*out = config_setting_get_int(setting);
config_destroy(&cfg);
return GL_CONF_OK;
}
/* A repos.conf url: https:// or file://, then at least one printable,
* non-space ASCII character. http:// and everything else are refused. */
static bool
valid_repo_url(const char *u)
{
const char *rest;
if (strncmp(u, "https://", 8) == 0)
rest = u + 8;
else if (strncmp(u, "file://", 7) == 0)
rest = u + 7;
else
return false;
if (*rest == '\0')
return false;
for (; *rest; rest++)
if (*rest < 0x21 || *rest > 0x7e)
return false;
return true;
}
/*
* Read and check a whole repos.conf (format: gl_conf_repo_keys). On
* GL_CONF_OK, *cfg is open (the caller config_destroy()s it) and *repos
* is its `repos` list; on anything else nothing is left open.
*/
static gl_conf_status_t
read_repos_conf(const char *path, config_t *cfg, config_setting_t **repos)
{
config_init(cfg);
if (!config_read_file(cfg, path)) {
config_destroy(cfg);
return GL_CONF_ERR_READ;
}
config_setting_t *root = config_root_setting(cfg);
config_setting_t *list = config_setting_get_member(root, "repos");
if (config_setting_length(root) != 1 || !list ||
config_setting_type(list) != CONFIG_TYPE_LIST)
goto invalid;
int n = config_setting_length(list);
for (int i = 0; i < n; i++) {
config_setting_t *e = config_setting_get_elem(list, (unsigned)i);
if (config_setting_type(e) != CONFIG_TYPE_GROUP)
goto invalid;
config_setting_t *name = config_setting_get_member(e, "name");
config_setting_t *url = config_setting_get_member(e, "url");
config_setting_t *keys = config_setting_get_member(e, "keys");
if (!name || !keys ||
config_setting_length(e) != (url ? 3 : 2))
goto invalid;
const char *nm = config_setting_get_string(name);
if (!nm || !gl_valid_pkg_ident(nm))
goto invalid;
if (url) {
const char *u = config_setting_get_string(url);
if (!u || !valid_repo_url(u))
goto invalid;
}
for (int j = 0; j < i; j++) {
config_setting_t *prev = config_setting_get_member(
config_setting_get_elem(list, (unsigned)j), "name");
if (strcmp(config_setting_get_string(prev), nm) == 0)
goto invalid;
}
if (config_setting_type(keys) != CONFIG_TYPE_ARRAY &&
config_setting_type(keys) != CONFIG_TYPE_LIST)
goto invalid;
int nk = config_setting_length(keys);
for (int k = 0; k < nk; k++) {
const char *key = config_setting_get_string(
config_setting_get_elem(keys, (unsigned)k));
if (!key || !gl_valid_pkg_ident(key))
goto invalid;
}
}
*repos = list;
return GL_CONF_OK;
invalid:
config_destroy(cfg);
return GL_CONF_ERR_INVALID;
}
/*
* The names of the keys repos.conf trusts for `repo` (REPO_DESIGN.txt
* section 3), '\n'-separated (each followed by '\n') into out.
* GL_CONF_OK out set (possibly "": the repo lists no keys)
* GL_CONF_ERR_READ file missing, unreadable, or not valid libconfig
* GL_CONF_ERR_MISSING the file is valid, but doesn't list `repo`
* GL_CONF_ERR_INVALID anything else than the format below
* GL_CONF_ERR_SPACE the names don't fit in outlen
* The WHOLE file is checked on every call, not only `repo`'s entry, and
* nothing unknown is ignored (a setting that silently never parses is
* CLAUDE.md bug 10's class):
* repos = ( { name = "base"; url = "https://..."; keys = [ "k" ]; }, ... );
* `repos` is the only top-level setting; each entry is a group with a
* name (a gl_valid_pkg_ident, unique), an optional url (valid_repo_url)
* and keys (an array or list of gl_valid_pkg_ident strings).
*/
gl_conf_status_t
gl_conf_repo_keys(const char *path, const char *repo, char *out, size_t outlen)
{
if (!out || outlen == 0)
return GL_CONF_ERR_SPACE;
out[0] = '\0';
config_t cfg;
config_setting_t *repos;
gl_conf_status_t st = read_repos_conf(path, &cfg, &repos);
if (st != GL_CONF_OK)
return st;
st = GL_CONF_ERR_MISSING;
int n = config_setting_length(repos);
for (int i = 0; i < n; i++) {
config_setting_t *e = config_setting_get_elem(repos, (unsigned)i);
if (strcmp(config_setting_get_string(
config_setting_get_member(e, "name")), repo) != 0)
continue;
config_setting_t *keys = config_setting_get_member(e, "keys");
size_t used = 0;
int nk = config_setting_length(keys);
for (int k = 0; k < nk; k++) {
const char *key = config_setting_get_string(
config_setting_get_elem(keys, (unsigned)k));
size_t len = strlen(key);
if (used + len + 2 > outlen) {
out[0] = '\0';
st = GL_CONF_ERR_SPACE;
goto out;
}
memcpy(out + used, key, len);
out[used + len] = '\n';
used += len + 1;
out[used] = '\0';
}
st = GL_CONF_OK;
break;
}
out:
config_destroy(&cfg);
return st;
}
/*
* Every repository repos.conf lists, in its order (= priority), one
* "NAME\tURL\n" line each into out; URL is empty for an entry without
* one. The whole file is checked as by gl_conf_repo_keys.
* GL_CONF_OK out set (possibly "": no repositories)
* GL_CONF_ERR_READ file missing, unreadable, or not valid libconfig
* GL_CONF_ERR_INVALID not the format gl_conf_repo_keys describes
* GL_CONF_ERR_SPACE the lines don't fit in outlen
*/
gl_conf_status_t
gl_conf_repos(const char *path, char *out, size_t outlen)
{
if (!out || outlen == 0)
return GL_CONF_ERR_SPACE;
out[0] = '\0';
config_t cfg;
config_setting_t *repos;
gl_conf_status_t st = read_repos_conf(path, &cfg, &repos);
if (st != GL_CONF_OK)
return st;
size_t used = 0;
int n = config_setting_length(repos);
for (int i = 0; i < n; i++) {
config_setting_t *e = config_setting_get_elem(repos, (unsigned)i);
const char *nm = config_setting_get_string(
config_setting_get_member(e, "name"));
config_setting_t *url = config_setting_get_member(e, "url");
const char *u = url ? config_setting_get_string(url) : "";
int len = snprintf(out + used, outlen - used, "%s\t%s\n", nm, u);
if (len < 0 || (size_t)len >= outlen - used) {
out[0] = '\0';
st = GL_CONF_ERR_SPACE;
break;
}
used += (size_t)len;
}
config_destroy(&cfg);
return st;
}
/* ------------------------------------------------------------------ */
/* Repository indexes (REPO_DESIGN.txt section 4) */
/* ------------------------------------------------------------------ */
/* A version as the store names it: "%d.%d.%d", each part decimal
* without leading zeros and fitting an int. Anything else would name a
* version directory the store never makes. */
static bool
valid_index_ver(const char *v)
{
for (int part = 0; part < 3; part++) {
const char *start = v;
long n = 0;
while (*v >= '0' && *v <= '9') {
n = n * 10 + (*v - '0');
if (n > INT_MAX)
return false;
v++;
}
if (v == start || (*start == '0' && v - start > 1))
return false;
if (part < 2 && *v++ != '.')
return false;
}
return *v == '\0';
}
static bool
valid_sha256_field(const char *s)
{
if (strncmp(s, "sha256:", 7) != 0 || strlen(s) != 7 + 64)
return false;
for (s += 7; *s; s++)
if (!((*s >= '0' && *s <= '9') || (*s >= 'a' && *s <= 'f')))
return false;
return true;
}
/* A group with exactly the members named in `names` (NULL-terminated). */
static bool
has_exactly(config_setting_t *g, const char *const *names)
{
if (!g || config_setting_type(g) != CONFIG_TYPE_GROUP)
return false;
int n = 0;
for (; names[n]; n++)
if (!config_setting_get_member(g, names[n]))
return false;
return config_setting_length(g) == n;
}
static const char *
str_member(config_setting_t *g, const char *name)
{
config_setting_t *m = config_setting_get_member(g, name);
return m && config_setting_type(m) == CONFIG_TYPE_STRING ?
config_setting_get_string(m) : NULL;
}
/* An integer member (int or int64), or false. */
static bool
int_member(config_setting_t *g, const char *name, int64_t *out)
{
config_setting_t *m = config_setting_get_member(g, name);
if (!m || (config_setting_type(m) != CONFIG_TYPE_INT &&
config_setting_type(m) != CONFIG_TYPE_INT64))
return false;
*out = (int64_t)config_setting_get_int64(m);
return true;
}
/* DEPS or CONFS: a list of { NAME; VER; } groups, as "NAME VER\n" lines
* into a new string (*out; "" for an empty list). */
static bool
read_relations(config_setting_t *pkg, const char *name, char **out)
{
static const char *const members[] = { "NAME", "VER", NULL };
config_setting_t *l = config_setting_get_member(pkg, name);
if (!l || config_setting_type(l) != CONFIG_TYPE_LIST)
return false;
int n = config_setting_length(l);
size_t cap = 1, used = 0;
for (int i = 0; i < n; i++) {
config_setting_t *r = config_setting_get_elem(l, (unsigned)i);
const char *nm, *ver;
if (!has_exactly(r, members) || !(nm = str_member(r, "NAME")) ||
!(ver = str_member(r, "VER")) || !gl_valid_pkg_ident(nm) ||
!valid_index_ver(ver))
return false;
cap += strlen(nm) + strlen(ver) + 2;
}
if (!(*out = malloc(cap)))
return false;
(*out)[0] = '\0';
for (int i = 0; i < n; i++) {
config_setting_t *r = config_setting_get_elem(l, (unsigned)i);
used += (size_t)snprintf(*out + used, cap - used, "%s %s\n",
str_member(r, "NAME"), str_member(r, "VER"));
}
return true;
}
void
gl_index_free(gl_index_t *idx)
{
if (!idx)
return;
for (size_t i = 0; i < idx->npkgs; i++) {
gl_index_pkg_t *p = &idx->pkgs[i];
free(p->name);
free(p->ver);
free(p->file);
free(p->sha256);
free(p->deps);
free(p->confs);
}
free(idx->pkgs);
free(idx->repo);
memset(idx, 0, sizeof(*idx));
}
/*
* Parse an index's bytes (text[0..len)) into *out, which is zeroed
* first; free it with gl_index_free on GL_INDEX_OK. The caller checks
* the signature over the SAME bytes first: nothing here is trusted
* otherwise, and nothing here checks SERIAL or VALID_UNTIL against
* anything (that is the caller's rollback/expiry policy).
*
* Strict, like repos.conf: exactly REPO, SERIAL (> 0), VALID_UNTIL
* (> 0) and PKGS at the top; each package exactly NAME, VER, FILE,
* SHA256, SIZE (>= 0), DEPS, CONFS; NAME a gl_valid_pkg_ident; VER as
* the store names versions; FILE exactly "NAME-VER.gpkg"; SHA256
* "sha256:" + 64 lowercase hex; DEPS/CONFS lists of { NAME; VER; }; no
* NAME + VER twice. A NUL or '@' anywhere is refused before parsing:
* an index never has one, and '@' is how libconfig spells @include.
* GL_INDEX_OK, GL_INDEX_ERR_FORMAT, GL_INDEX_ERR_REPO (well formed,
* but REPO is not `repo`), GL_INDEX_ERR_NOMEM
*/
gl_index_status_t
gl_index_parse(const char *text, size_t len, const char *repo, gl_index_t *out)
{
static const char *const top[] = { "REPO", "SERIAL", "VALID_UNTIL", "PKGS", NULL };
static const char *const fields[] = { "NAME", "VER", "FILE", "SHA256",
"SIZE", "DEPS", "CONFS", NULL };
if (!out)
return GL_INDEX_ERR_FORMAT;
memset(out, 0, sizeof(*out));
if (!text || !repo || memchr(text, '\0', len) || memchr(text, '@', len))
return GL_INDEX_ERR_FORMAT;
char *buf = malloc(len + 1);
if (!buf)
return GL_INDEX_ERR_NOMEM;
memcpy(buf, text, len);
buf[len] = '\0';
config_t cfg;
config_init(&cfg);
int ok = config_read_string(&cfg, buf);
free(buf);
if (!ok) {
config_destroy(&cfg);
return GL_INDEX_ERR_FORMAT;
}
gl_index_status_t st = GL_INDEX_ERR_FORMAT;
config_setting_t *root = config_root_setting(&cfg);
const char *r = str_member(root, "REPO");
config_setting_t *pkgs = config_setting_get_member(root, "PKGS");
if (config_setting_length(root) != 4 || !has_exactly(root, top) ||
!r || !gl_valid_pkg_ident(r) ||
!int_member(root, "SERIAL", &out->serial) || out->serial <= 0 ||
!int_member(root, "VALID_UNTIL", &out->valid_until) ||
out->valid_until <= 0 ||
config_setting_type(pkgs) != CONFIG_TYPE_LIST)
goto fail;
size_t n = (size_t)config_setting_length(pkgs);
if (n && !(out->pkgs = calloc(n, sizeof(*out->pkgs)))) {
st = GL_INDEX_ERR_NOMEM;
goto fail;
}
for (size_t i = 0; i < n; i++) {
config_setting_t *e = config_setting_get_elem(pkgs, (unsigned)i);
gl_index_pkg_t *p = &out->pkgs[i];
out->npkgs = i + 1;
const char *nm, *ver, *file, *sha;
if (!has_exactly(e, fields) ||
!(nm = str_member(e, "NAME")) || !gl_valid_pkg_ident(nm) ||
!(ver = str_member(e, "VER")) || !valid_index_ver(ver) ||
!(file = str_member(e, "FILE")) ||
!(sha = str_member(e, "SHA256")) || !valid_sha256_field(sha) ||
!int_member(e, "SIZE", &p->size) || p->size < 0)
goto fail;
char want[PATH_MAX];
snprintf(want, sizeof want, "%s-%s.gpkg", nm, ver);
if (strcmp(file, want) != 0)
goto fail;
for (size_t j = 0; j < i; j++)
if (strcmp(out->pkgs[j].name, nm) == 0 &&
strcmp(out->pkgs[j].ver, ver) == 0)
goto fail;
if (!(p->name = strdup(nm)) || !(p->ver = strdup(ver)) ||
!(p->file = strdup(file)) || !(p->sha256 = strdup(sha))) {
st = GL_INDEX_ERR_NOMEM;
goto fail;
}
if (!read_relations(e, "DEPS", &p->deps) ||
!read_relations(e, "CONFS", &p->confs))
goto fail;
}
if (strcmp(r, repo) != 0) {
st = GL_INDEX_ERR_REPO;
goto fail;
}
if (!(out->repo = strdup(r))) {
st = GL_INDEX_ERR_NOMEM;
goto fail;
}
config_destroy(&cfg);
return GL_INDEX_OK;
fail:
config_destroy(&cfg);
gl_index_free(out);
return st;
}