/* * bulk - fetch pre-built binary packages from rawnix.org bulk repo * https://rawnix.org * * Copyright (c) 2025 zorz@gmx.com * SPDX-License-Identifier: BSD-2-Clause * * Usage: bulk [package2 ...] * * Reads /etc/ports/ports.rsync for host/token/collection config. * Finds the local port directory for each package (by scanning for MAKEPKG). * Maps it to the corresponding bulk/ path on the server. * * For each package: * 1. Read MAKEPKG to derive the expected binary filename: * #-.pkg.tar.gz * 2. Look up the recorded SHA256 for that filename in the local * `.signature` file (which arrived via the ports rsync earlier). * Missing line → strict error, no fetch. * 3. Verify the signify signature over `.signature`: the key in the * local MAKEPKG's signify() must match a trusted key in * /etc/ports/keys/.pub, and signify is given that trusted file. * Bad/absent signature or untrusted key → refuse. * 4. If a local copy already matches the recorded sum, skip the fetch. * 5. Otherwise rsync the binary from bulk/// and verify * against the recorded sum. Mismatch → unlink + error. * * Trust chain: the SHA256 line is only trusted once step 3 confirms * `.signature` was signed with a key installed locally in * /etc/ports/keys. A tampered mirror or ports tree cannot forge a * matching signed .signature without the secret key. */ #include "util.h" #include "db.h" #include #include #include #include #include #include #include #include #include #include /* ── Config ──────────────────────────────────────────────────────────── */ #define RSYNC_CONF "/etc/ports/ports.rsync" #define MAX_COLS 32 typedef struct { char host[256]; char token[256]; char col_remote[MAX_COLS][128]; char col_local[MAX_COLS][PATH_MAX_LEN]; int col_count; } bulk_conf_t; static int load_conf(const char *path, bulk_conf_t *c) { FILE *fp; char line[LINE_MAX_LEN]; char *key, *val, *eq; memset(c, 0, sizeof(*c)); fp = fopen(path, "r"); if (!fp) { warn("%s", path); return -1; } while (fgets(line, sizeof(line), fp)) { line[strcspn(line, "\n")] = '\0'; key = str_trim(line); if (!*key || *key == '#') continue; eq = strchr(key, '='); if (!eq) continue; *eq = '\0'; val = str_trim(eq + 1); key = str_trim(key); if (strcmp(key, "host") == 0) { snprintf(c->host, sizeof(c->host), "%s", val); } else if (strcmp(key, "token") == 0) { snprintf(c->token, sizeof(c->token), "%s", val); } else if (strcmp(key, "module") == 0) { /* ignore — always use "bulk" module */ } else { if (c->col_count < MAX_COLS) { snprintf(c->col_remote[c->col_count], sizeof(c->col_remote[0]), "%s", key); snprintf(c->col_local[c->col_count], sizeof(c->col_local[0]), "%s", val); c->col_count++; } } } fclose(fp); if (!c->host[0]) { warnx("%s: missing 'host'", path); return -1; } if (!c->token[0]) { warnx("%s: missing 'token'", path); return -1; } if (c->col_count == 0) { warnx("%s: no collection mappings", path); return -1; } return 0; } /* ── MAKEPKG name/version/release ─────────────────────────────────────── */ /* * Read just name=, version=, release= from a MAKEPKG. No array support, * no build() body — this is the same shape as ports.c's parse_makepkg_nvr * but inlined here so bulk stays self-contained (one .o, no linker fan-out). * * Strips inline `#` comments quote-aware, then strips surrounding quotes. * Returns 0 on success, -1 if any of the three fields is missing. */ static void strip_inline_comment(char *s) { int in_quote = 0; char qchar = 0; char *p; for (p = s; *p; p++) { if (!in_quote && (*p == '"' || *p == '\'')) { in_quote = 1; qchar = *p; } else if (in_quote && *p == qchar) { in_quote = 0; } else if (!in_quote && *p == '#') { *p = '\0'; return; } } } static char *strip_quotes(char *s) { size_t len; s = str_trim(s); len = strlen(s); if (len >= 2 && ((s[0] == '"' && s[len-1] == '"') || (s[0] == '\'' && s[len-1] == '\''))) { s[len-1] = '\0'; s++; } return s; } static int read_makepkg_nvr(const char *path, char *name, size_t namesz, char *version, size_t versz, char *release, size_t relsz) { FILE *fp; char line[LINE_MAX_LEN]; int got_name = 0, got_ver = 0, got_rel = 0; name[0] = version[0] = release[0] = '\0'; fp = fopen(path, "r"); if (!fp) return -1; while (fgets(line, sizeof(line), fp)) { char *p, *val; line[strcspn(line, "\n")] = '\0'; p = str_trim(line); if (!*p || *p == '#') continue; if (!got_name && strncmp(p, "name=", 5) == 0) { val = p + 5; strip_inline_comment(val); val = strip_quotes(val); snprintf(name, namesz, "%s", val); got_name = 1; } else if (!got_ver && strncmp(p, "version=", 8) == 0) { val = p + 8; strip_inline_comment(val); val = strip_quotes(val); snprintf(version, versz, "%s", val); got_ver = 1; } else if (!got_rel && strncmp(p, "release=", 8) == 0) { val = p + 8; strip_inline_comment(val); val = strip_quotes(val); snprintf(release, relsz, "%s", val); got_rel = 1; } if (got_name && got_ver && got_rel) break; } fclose(fp); return (got_name && got_ver && got_rel) ? 0 : -1; } /* ── sha256 via coreutils ─────────────────────────────────────────────── */ /* * Compute sha256 of `path` by forking `sha256sum`. Output: 64-char hex * lowercase string in `out_hex` (out_hex must be >= 65 bytes). We avoid * linking openssl into bulk; coreutils' sha256sum is part of the rawnix.org * base set so it's always present. */ static int compute_sha256(const char *path, char *out_hex) { int pipefd[2]; pid_t pid; int status; char buf[80]; ssize_t total = 0, n; if (pipe(pipefd) < 0) { warn("pipe"); return -1; } pid = fork(); if (pid < 0) { warn("fork"); close(pipefd[0]); close(pipefd[1]); return -1; } if (pid == 0) { dup2(pipefd[1], STDOUT_FILENO); close(pipefd[0]); close(pipefd[1]); execlp("sha256sum", "sha256sum", path, (char *)NULL); _exit(127); } close(pipefd[1]); while (total < (ssize_t)sizeof(buf) - 1) { n = read(pipefd[0], buf + total, sizeof(buf) - 1 - (size_t)total); if (n <= 0) break; total += n; } close(pipefd[0]); if (waitpid(pid, &status, 0) < 0) return -1; if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return -1; if (total < 64) return -1; memcpy(out_hex, buf, 64); out_hex[64] = '\0'; /* sha256sum emits lowercase already; verify for safety */ { int i; for (i = 0; i < 64; i++) if (!((out_hex[i] >= '0' && out_hex[i] <= '9') || (out_hex[i] >= 'a' && out_hex[i] <= 'f'))) return -1; } return 0; } /* ── .signature signify verification ─────────────────────────────────── */ /* same temp policy as mkpkg's signature.c:make_tmpfd */ static int bulk_tmpfd(char *path, size_t pathsz) { const char *tmp = getenv("TMPDIR"); if (!tmp || !tmp[0]) tmp = "/dev/shm"; snprintf(path, pathsz, "%s/bulk.XXXXXX", tmp); return mkstemp(path); } /* * Extract the signify() body from MAKEPKG into a temp file. The body * carries the "untrusted comment:" line plus the base64 key line — * exactly the on-disk pubkey format `signify -p` expects. Mirrors the * capture rules of mkpkg's parser: trimmed, non-empty body lines * between the braces, verbatim. Returns 0 with the temp path in * tmp_path, or -1 if MAKEPKG is unreadable or has no signify() body. */ static int extract_pubkey(const char *mp_path, char *tmp_path, size_t sz) { FILE *in, *out; char line[LINE_MAX_LEN]; int fd, in_body = 0, seen_open = 0, lines = 0; in = fopen(mp_path, "r"); if (!in) return -1; fd = bulk_tmpfd(tmp_path, sz); if (fd < 0) { fclose(in); return -1; } out = fdopen(fd, "w"); if (!out) { close(fd); unlink(tmp_path); fclose(in); return -1; } while (fgets(line, sizeof(line), in)) { char *t; line[strcspn(line, "\n")] = '\0'; t = str_trim(line); if (!in_body) { if (strncmp(t, "signify()", 9) == 0) { in_body = 1; if (strchr(t + 9, '{')) seen_open = 1; } continue; } if (!seen_open) { if (strcmp(t, "{") == 0) seen_open = 1; continue; } if (strcmp(t, "}") == 0) break; if (t[0] == '\0') continue; fprintf(out, "%s\n", t); lines++; } fclose(in); fclose(out); if (lines == 0) { unlink(tmp_path); return -1; } return 0; } /* * Split .signature at the first blank line into the signify base64 half * (with the dummy comment signify requires prepended) and the checksum * half. Mirrors mkpkg's signature.c:split_signature so both tools * accept the identical on-disk format. */ static int split_sig(const char *sig_path, char *tmp_sig, size_t ssz, char *tmp_cks, size_t csz) { FILE *in, *fsig, *fcks; char line[1024]; int fd, in_checksums = 0; in = fopen(sig_path, "r"); if (!in) return -1; fd = bulk_tmpfd(tmp_sig, ssz); if (fd < 0) { fclose(in); return -1; } fsig = fdopen(fd, "w"); if (!fsig) { close(fd); unlink(tmp_sig); fclose(in); return -1; } fd = bulk_tmpfd(tmp_cks, csz); if (fd < 0) { fclose(fsig); unlink(tmp_sig); fclose(in); return -1; } fcks = fdopen(fd, "w"); if (!fcks) { close(fd); unlink(tmp_cks); fclose(fsig); unlink(tmp_sig); fclose(in); return -1; } fprintf(fsig, "untrusted comment: mkpkg\n"); while (fgets(line, sizeof(line), in)) { if (!in_checksums) { if (line[0] == '\n' || line[0] == '\r') { in_checksums = 1; continue; } if (strncmp(line, "untrusted comment:", 18) == 0) continue; fputs(line, fsig); } else { fputs(line, fcks); } } fclose(in); fclose(fsig); fclose(fcks); return 0; } /* * Verify the signify signature over the checksum block of .signature. * The key named in the local MAKEPKG's signify() must also be one of * TRUSTED_KEYS_DIR/.pub, and signify is handed that trusted file — * MAKEPKG and .signature both arrive over `ports -u`, so a key taken * from MAKEPKG alone vouches only for itself. Fail closed: any * missing piece (no signify() in MAKEPKG, untrusted key, unreadable * .signature, signify binary absent, bad signature) refuses the * package. Prints its own diagnostics; returns 0 only on a good * signature from a trusted key. */ static int verify_sig_file(const char *name, const char *mp_path, const char *sig_path) { char keytmp[PATH_MAX_LEN]; char pubkey[PATH_MAX_LEN]; char keytext[1024]; char tmp_sig[PATH_MAX_LEN]; char tmp_cks[PATH_MAX_LEN]; FILE *kf; size_t kn; pid_t pid; int status, ret = -1; if (extract_pubkey(mp_path, keytmp, sizeof(keytmp)) != 0) { warnx("%s: error: no signify() public key in MAKEPKG", name); return -1; } kf = fopen(keytmp, "r"); kn = kf ? fread(keytext, 1, sizeof(keytext) - 1, kf) : 0; if (kf) fclose(kf); unlink(keytmp); keytext[kn] = '\0'; if (signify_trusted_pubkey(keytext, pubkey, sizeof(pubkey)) != 0) { warnx("%s: error: signify() key in MAKEPKG is not trusted " "(no matching key in %s/*.pub)", name, TRUSTED_KEYS_DIR); return -1; } if (split_sig(sig_path, tmp_sig, sizeof(tmp_sig), tmp_cks, sizeof(tmp_cks)) != 0) { warnx("%s: error: cannot read .signature", name); return -1; } pid = fork(); if (pid == 0) { int devnull = open("/dev/null", O_WRONLY); if (devnull >= 0) { dup2(devnull, STDOUT_FILENO); dup2(devnull, STDERR_FILENO); close(devnull); } execlp("signify", "signify", "-V", "-p", pubkey, "-m", tmp_cks, "-x", tmp_sig, (char *)NULL); _exit(127); } if (pid > 0 && waitpid(pid, &status, 0) == pid && WIFEXITED(status)) { if (WEXITSTATUS(status) == 0) ret = 0; else if (WEXITSTATUS(status) == 127) warnx("%s: error: cannot run signify (not installed?)", name); else warnx("%s: error: .signature failed signify verification", name); } else { warnx("%s: error: signify: fork/wait failed", name); } unlink(tmp_sig); unlink(tmp_cks); return ret; } /* ── .signature parser ────────────────────────────────────────────────── */ /* * Look for the line `SHA256 () = ` inside `.signature`, * matching the requested filename exactly. On success, copies the 64-hex * sum to out_hex (must be >= 65 bytes) and returns 0. Returns -1 if the * file can't be opened or no matching line exists. */ static int sig_lookup_sha(const char *sig_path, const char *filename, char *out_hex) { FILE *fp; char line[LINE_MAX_LEN]; char prefix[PATH_MAX_LEN]; size_t prefix_len; int found = -1; snprintf(prefix, sizeof(prefix), "SHA256 (%s) = ", filename); prefix_len = strlen(prefix); fp = fopen(sig_path, "r"); if (!fp) return -1; while (fgets(line, sizeof(line), fp)) { size_t llen; line[strcspn(line, "\n")] = '\0'; llen = strlen(line); if (llen < prefix_len + 64) continue; if (strncmp(line, prefix, prefix_len) != 0) continue; memcpy(out_hex, line + prefix_len, 64); out_hex[64] = '\0'; found = 0; break; } fclose(fp); return found; } /* ── Port finder ─────────────────────────────────────────────────────── */ static int find_port(const bulk_conf_t *c, const char *name, char *port_dir, size_t port_dir_len, char *remote_col, size_t remote_col_len) { int i; struct stat st; char candidate[PATH_MAX_LEN]; char makepkg[PATH_MAX_LEN]; for (i = 0; i < c->col_count; i++) { snprintf(candidate, sizeof(candidate), "%s/%s", c->col_local[i], name); snprintf(makepkg, sizeof(makepkg), "%s/MAKEPKG", candidate); if (stat(makepkg, &st) == 0 && S_ISREG(st.st_mode)) { snprintf(port_dir, port_dir_len, "%s", candidate); snprintf(remote_col, remote_col_len, "%s", c->col_remote[i]); return 1; } } return 0; } /* ── rsync fetch ─────────────────────────────────────────────────────── */ static int fetch_binary(const bulk_conf_t *c, const char *remote_col, const char *pkg_name, const char *port_dir) { char source[PATH_MAX_LEN]; char *argv[16]; int argc = 0; pid_t pid; int status; snprintf(source, sizeof(source), "%s@%s::bulk/%s/%s/", c->token, c->host, remote_col, pkg_name); argv[argc++] = "rsync"; argv[argc++] = "-az"; argv[argc++] = "--include=*.pkg.tar.*"; argv[argc++] = "--exclude=*"; argv[argc++] = source; argv[argc++] = (char *)port_dir; argv[argc] = NULL; pid = fork(); if (pid < 0) { warn("fork"); return -1; } if (pid == 0) { if (c->token[0]) setenv("RSYNC_PASSWORD", c->token, 1); execvp("rsync", argv); err(1, "rsync"); } if (waitpid(pid, &status, 0) < 0) { warn("waitpid"); return -1; } return (WIFEXITED(status) && WEXITSTATUS(status) == 0) ? 0 : -1; } /* ── Usage / main ────────────────────────────────────────────────────── */ static void usage(void) { printf("usage: bulk [-u | [package2 ...]]\n" " (no flag) fetch compiled ports\n" " -u update all outdated compiled ports\n"); } /* * Fetch+verify one package by name. * * Returns: * 0 local copy already current — nothing fetched * 1 fetched and verified * -1 error (printed via warnx) * * `quiet` suppresses the "up to date" line. Useful in -u mode where * we'd otherwise emit one such line per installed package on a * healthy system. Fetches and errors are always loud. */ static int fetch_one(const bulk_conf_t *conf, const char *name, int quiet) { char port_dir[PATH_MAX_LEN]; char remote_col[128]; char mp_path[PATH_MAX_LEN]; char sig_path[PATH_MAX_LEN]; char bin_path[PATH_MAX_LEN]; char bin_name[PATH_MAX_LEN]; char nm[128], ver[64], rel[32]; char expected[65], actual[65]; if (!find_port(conf, name, port_dir, sizeof(port_dir), remote_col, sizeof(remote_col))) { warnx("%s: error: port not found in any collection", name); return -1; } /* * A locked port builds from MAKEPKG.lock; the binary on the * server was built from upstream's MAKEPKG. Installing it would * discard the lock silently, and nothing downstream would catch * it because bulk never runs mkpkg -- the recipe preference in * init_config() is on the source path only. Refuse; the caller * falls back to building. */ snprintf(mp_path, sizeof(mp_path), "%s/MAKEPKG.lock", port_dir); if (access(mp_path, F_OK) == 0) { warnx("%s: error: port is locked (MAKEPKG.lock), " "refusing binary fetch", name); return -1; } snprintf(mp_path, sizeof(mp_path), "%s/MAKEPKG", port_dir); if (read_makepkg_nvr(mp_path, nm, sizeof(nm), ver, sizeof(ver), rel, sizeof(rel)) != 0) { warnx("%s: error: cannot parse MAKEPKG", name); return -1; } snprintf(bin_name, sizeof(bin_name), "%s#%s-%s.pkg.tar.gz", nm, ver, rel); snprintf(bin_path, sizeof(bin_path), "%s/%s", port_dir, bin_name); snprintf(sig_path, sizeof(sig_path), "%s/.signature", port_dir); if (sig_lookup_sha(sig_path, bin_name, expected) != 0) { warnx("%s: error: no SHA256 line for %s in .signature", name, bin_name); return -1; } /* * Authenticate the .signature itself before trusting its SHA256 * line. The recorded sum is only as trustworthy as the file it * lives in; that file is signify-signed by the port maintainer, * and the pubkey travels in the local MAKEPKG (ports tree), not * over the bulk transport. Without this, a tampered mirror could * ship a matching (package, .signature) pair and pass the sha * check. Fail closed. */ if (verify_sig_file(name, mp_path, sig_path) != 0) return -1; if (file_exists(bin_path)) { if (compute_sha256(bin_path, actual) == 0 && strcmp(actual, expected) == 0) { if (!quiet) printf("bulk: %s: up to date : %s\n", name, bin_name); return 0; } unlink(bin_path); } printf("bulk: %s: fetching from %s/%s\n", name, conf->host, remote_col); if (fetch_binary(conf, remote_col, name, port_dir) != 0) { warnx("%s: error: fetch failed", name); return -1; } if (compute_sha256(bin_path, actual) != 0) { warnx("%s: error: cannot hash fetched file", name); unlink(bin_path); return -1; } if (strcmp(actual, expected) != 0) { fprintf(stderr, "bulk: %s: error: sha256 mismatch (removed):\n" " expected: %s\n" " got: %s\n", name, expected, actual); unlink(bin_path); return -1; } printf("bulk: %s: verified: ok (%s)\n", name, actual); return 1; } /* * -u mode: ensure every installed package has its current binary on * disk locally. Walks every collection in ports.rsync, parses each * port's MAKEPKG nvr, looks up the installed version in * /var/lib/pkg/db, and for any installed package, hands it to * fetch_one — which decides via sha whether the local file is current * and either skips or fetches. * * The version comparison `port_ver != installed_ver` was the previous * filter; that missed cases where the binary was simply absent from * disk (deleted, never built, fresh checkout, etc.). Now the * "is local correct?" check that fetch_one already does is the one * source of truth. * * fetch_one is invoked in quiet mode so the common "all up to date" * case doesn't emit one line per installed package. When nothing * was actually fetched and nothing failed, prints "nothing to update" * as a friendly summary. */ static int fetch_outdated_installed(const bulk_conf_t *conf) { pkgdb_t *db; DIR *dp; struct dirent *de; struct stat st; int i, ret = 0, fetched = 0; db = db_open("/", 0); if (!db) { warnx("error: cannot open /var/lib/pkg/db"); return 1; } for (i = 0; i < conf->col_count; i++) { const char *col_path = conf->col_local[i]; dp = opendir(col_path); if (!dp) continue; while ((de = readdir(dp)) != NULL) { char makepkg[PATH_MAX_LEN]; char lockfile[PATH_MAX_LEN]; char nm[128], ver[64], rel[32]; int r; if (de->d_name[0] == '.') continue; snprintf(makepkg, sizeof(makepkg), "%s/%s/MAKEPKG", col_path, de->d_name); if (stat(makepkg, &st) != 0 || !S_ISREG(st.st_mode)) continue; /* * Locked port: fetch_one would refuse and set the exit * status. A sweep across every installed package must * not fail because one port is pinned, so skip it here. */ snprintf(lockfile, sizeof(lockfile), "%s/%s/MAKEPKG.lock", col_path, de->d_name); if (access(lockfile, F_OK) == 0) continue; if (read_makepkg_nvr(makepkg, nm, sizeof(nm), ver, sizeof(ver), rel, sizeof(rel)) != 0) continue; if (!db_find(db, nm)) continue; /* package not installed — skip */ r = fetch_one(conf, nm, /*quiet=*/1); if (r < 0) ret = 1; else if (r > 0) fetched++; } closedir(dp); } db_close(db); if (fetched == 0 && ret == 0) printf("bulk: nothing to update\n"); return ret; } int main(int argc, char **argv) { bulk_conf_t conf; int i, ret = 0; int update_mode = 0; if (argc < 2 || strcmp(argv[1], "-h") == 0 || strcmp(argv[1], "--help") == 0) { usage(); return argc < 2 ? 1 : 0; } if (strcmp(argv[1], "-u") == 0 || strcmp(argv[1], "--update") == 0) { update_mode = 1; if (argc > 2) warnx("warning: -u takes no arguments; ignoring extras"); } if (load_conf(RSYNC_CONF, &conf) != 0) errx(1, "cannot load %s", RSYNC_CONF); if (update_mode) return fetch_outdated_installed(&conf); for (i = 1; i < argc; i++) { if (fetch_one(&conf, argv[i], /*quiet=*/0) < 0) ret = 1; } return ret; }