/* * addpkg.c - install or upgrade a package * https://rawnix.org * * Copyright (c) 2025 zorz@gmx.com * SPDX-License-Identifier: BSD-2-Clause * * Config file (.new) handling: * fresh install — extract etc/ files normally * upgrade — if etc/foo already exists, extract as etc/foo.new * replacing any previous .new * delpkg — removes .new files, leaves user-modified originals */ #include "db.h" #include "archive.h" #include "meta.h" #include "util.h" #include #include #include #include #include #include #include #include #include #include static void error(const char *fmt, ...) { va_list ap; va_start(ap, fmt); fputs("addpkg: error: ", stderr); vfprintf(stderr, fmt, ap); fputc('\n', stderr); va_end(ap); } /* ── Footprint guard ─────────────────────────────────────────────────── */ static const char *fp_path_col(const char *line) { const char *p = strchr(line, '\t'); if (!p) return line; p = strchr(p + 1, '\t'); if (!p) return line; return p + 1; } /* * Copy the path column into `out`, stripping any trailing annotation * (" -> target", " (EMPTY)", " (maj, min)") — same semantics as * footprint.c, so a symlink whose target changed compares as the same * path and reports as one "changed" entry, not missing+new. */ static const char *fp_path_only(const char *line, char *out, size_t outsz) { const char *p = fp_path_col(line); size_t len = strlen(p); size_t i, end = len; for (i = 0; i < len; i++) { if (p[i] != ' ') continue; if (i + 3 < len && p[i+1] == '-' && p[i+2] == '>' && p[i+3] == ' ') { end = i; break; } if (i + 1 < len && p[i+1] == '(') { end = i; break; } } if (end >= outsz) end = outsz - 1; memcpy(out, p, end); out[end] = '\0'; return out; } static int fp_line_cmp(const void *a, const void *b) { char ka[PATH_MAX_LEN * 2], kb[PATH_MAX_LEN * 2]; fp_path_only(*(const char **)a, ka, sizeof(ka)); fp_path_only(*(const char **)b, kb, sizeof(kb)); return strcmp(ka, kb); } static strarray_t *read_fp_file(const char *path) { FILE *fp; strarray_t *a; char line[PATH_MAX_LEN * 2]; fp = fopen(path, "r"); if (!fp) return NULL; a = strarray_new(); while (fgets(line, sizeof(line), fp)) { line[strcspn(line, "\n")] = '\0'; if (line[0]) strarray_add(a, line); } fclose(fp); if (a->count > 1) qsort(a->items, a->count, sizeof(char *), fp_line_cmp); return a; } static strarray_t *fp_from_pkg(const char *pkgfile) { const char *tmpdir = getenv("TMPDIR"); char tmp[PATH_MAX_LEN]; int fd; pid_t pid; int status; strarray_t *a; if (!tmpdir || !tmpdir[0]) tmpdir = "/tmp"; snprintf(tmp, sizeof(tmp), "%s/addpkg_fp.XXXXXX", tmpdir); fd = mkstemp(tmp); if (fd < 0) return NULL; close(fd); pid = fork(); if (pid == 0) { int out = open(tmp, O_WRONLY | O_TRUNC); if (out >= 0) { dup2(out, STDOUT_FILENO); close(out); } arc_footprint(pkgfile); fflush(stdout); _exit(0); } if (pid < 0) { unlink(tmp); return NULL; } waitpid(pid, &status, 0); a = read_fp_file(tmp); unlink(tmp); return a; } static int load_ignore_new(void) { FILE *fp; char line[PATH_MAX_LEN]; fp = fopen("/etc/mkpkg.conf", "r"); if (!fp) return 0; /* no conf — use documented default: block new files */ while (fgets(line, sizeof(line), fp)) { char *p = line; while (*p == ' ' || *p == '\t') p++; if (*p == '#' || *p == '\0' || *p == '\n') continue; if (strncmp(p, "IGNORE_NEW", 10) == 0) { p += 10; while (*p == ' ' || *p == '\t' || *p == '=') p++; fclose(fp); return (strncmp(p, "yes", 3) == 0) ? 1 : 0; } } fclose(fp); return 0; /* default: block on new files */ } /* * Compare the footprint embedded in the package against the .footprint * file in the same directory. Returns 0 if clean, 1 if mismatch. * If no .footprint file exists, skip the check (fresh port, no history). * * A locked port (MAKEPKG.lock beside the package) is checked against * .footprint.lock — the footprint of the recipe mkpkg built it from. * .footprint there is upstream's, overwritten by every `ports -u`, and * refused every locked rebuild whose file list had drifted from it. * No .footprint.lock yet is the same as no .footprint: skipped, and * mkpkg writes one on the next locked build. */ static int check_pkg_footprint(const char *pkgfile, int ignore_new) { char fp_path[PATH_MAX_LEN]; char dirbuf[PATH_MAX_LEN]; char *slash; strarray_t *stored, *current; size_t si, ci; int errors = 0, new_files = 0; snprintf(dirbuf, sizeof(dirbuf), "%s", pkgfile); slash = strrchr(dirbuf, '/'); if (slash) *slash = '\0'; else snprintf(dirbuf, sizeof(dirbuf), "."); snprintf(fp_path, sizeof(fp_path), "%s/MAKEPKG.lock", dirbuf); snprintf(fp_path, sizeof(fp_path), "%s/%s", dirbuf, access(fp_path, F_OK) == 0 ? ".footprint.lock" : ".footprint"); if (access(fp_path, F_OK) != 0) return 0; /* no .footprint — skip */ stored = read_fp_file(fp_path); current = fp_from_pkg(pkgfile); /* * Fail closed. A .footprint that exists but cannot be read, or a * package whose contents cannot be listed, is not a clean check -- * passing it would install exactly what the guard is there to stop. * -f still skips the whole check. */ if (!stored || !current) { if (!stored) fprintf(stderr, "addpkg: footprint: cannot read %s\n", fp_path); else fprintf(stderr, "addpkg: footprint: cannot list %s\n", pkgfile); strarray_free(stored); strarray_free(current); return 1; } si = 0; ci = 0; { int hdr_changed = 0, hdr_missing = 0, hdr_new = 0; while (si < stored->count || ci < current->count) { char s_key[PATH_MAX_LEN * 2], c_key[PATH_MAX_LEN * 2]; const char *sp = si < stored->count ? fp_path_only(stored->items[si], s_key, sizeof(s_key)) : NULL; const char *cp = ci < current->count ? fp_path_only(current->items[ci], c_key, sizeof(c_key)) : NULL; int cmp; if (!sp) cmp = 1; else if (!cp) cmp = -1; else cmp = strcmp(sp, cp); if (cmp == 0) { if (strcmp(stored->items[si], current->items[ci]) != 0) { if (!hdr_changed) { fprintf(stderr, "addpkg: footprint: changed\n"); hdr_changed = 1; } fprintf(stderr, "%s\n", current->items[ci]); errors++; } si++; ci++; } else if (cmp < 0) { if (!hdr_missing) { fprintf(stderr, "addpkg: footprint: missing\n"); hdr_missing = 1; } fprintf(stderr, "%s\n", stored->items[si]); errors++; si++; } else { if (!ignore_new) { if (!hdr_new) { fprintf(stderr, "addpkg: footprint: new\n"); hdr_new = 1; } fprintf(stderr, "%s\n", current->items[ci]); new_files++; } /* ignore_new=1: new files silently accepted */ ci++; } } } strarray_free(stored); strarray_free(current); return (errors > 0 || new_files > 0) ? 1 : 0; } static void usage(void) { fprintf(stderr, "usage: addpkg [-u] [-f] [-r root] \n" " -u upgrade installed package\n" " -f force install, overwrite conflicts\n" " -r alternative installation root\n" ); exit(1); } /* ── Conflict detection ──────────────────────────────────────────────── */ static strarray_t *find_conflicts(pkgdb_t *db, const char *name, strarray_t *files) { strarray_t *conflicts = strarray_new(); pkg_t *p; size_t i, len; /* sort all file lists once before the scan */ for (p = db->head; p; p = p->next) strarray_sort(p->files); for (p = db->head; p; p = p->next) { if (strcmp(p->name, name) == 0) continue; for (i = 0; i < files->count; i++) { len = strlen(files->items[i]); /* directories are shared, not conflicts */ if (len > 0 && files->items[i][len - 1] == '/') continue; if (strarray_contains(p->files, files->items[i])) strarray_add(conflicts, files->items[i]); } } return conflicts; } /* ── Orphan removal ──────────────────────────────────────────────────── */ static void remove_orphans(const char *root, strarray_t *old_files, strarray_t *new_files, pkgdb_t *db, const char *pkg_name) { size_t i; pkg_t *p; char *path; strarray_sort(old_files); strarray_sort(new_files); for (i = old_files->count; i > 0; i--) { const char *f = old_files->items[i - 1]; size_t flen = strlen(f); int refs = 0; int is_etc; if (strarray_contains(new_files, f)) continue; for (p = db->head; p && !refs; p = p->next) { if (strcmp(p->name, pkg_name) == 0) continue; if (strarray_contains(p->files, f)) refs = 1; } if (refs) continue; /* * etc/ files orphaned by an upgrade are NOT deleted. * The user may have edited them; upstream dropping a config * from the manifest must not silently destroy local edits. * The file is left on disk and the user is notified — they * can remove it manually if they no longer want it. * * Directories under etc/ (trailing slash) are still removed * if empty (remove() == rmdir() for dirs), since an empty * config directory carries no user state worth preserving. */ is_etc = (flen >= 4 && strncmp(f, "etc/", 4) == 0 && flen > 0 && f[flen - 1] != '/'); path = path_join(root, f); if (is_etc && file_exists(path)) { fprintf(stderr, "addpkg: notice: keeping orphaned config: %s\n", f); } else { remove(path); } free(path); } } /* ── move helper ─────────────────────────────────────────────────────── */ /* * Move src to dst, falling back to copy+unlink across filesystems. * * rename(2) returns EXDEV when the two paths are on different mounts, * which is the normal case here: the staging directory lives under * TMPDIR (typically tmpfs) while the destination is /etc on the root * filesystem. The previous code called rename() and discarded the * result, so every .new file silently failed to appear and upgrades * looked clean while doing nothing. * * The copy path preserves the mode of the extracted file; ownership * is root:root, which is what tar -p produced for config files anyway. */ static int move_file(const char *src, const char *dst) { int in, out; struct stat st; char buf[1 << 16]; ssize_t n; if (rename(src, dst) == 0) return 0; if (errno != EXDEV) return -1; in = open(src, O_RDONLY); if (in < 0) return -1; if (fstat(in, &st) != 0) { close(in); return -1; } out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, st.st_mode & 07777); if (out < 0) { close(in); return -1; } while ((n = read(in, buf, sizeof(buf))) > 0) { ssize_t off = 0; while (off < n) { ssize_t w = write(out, buf + off, (size_t)(n - off)); if (w < 0) { if (errno == EINTR) continue; close(in); close(out); unlink(dst); return -1; } off += w; } } if (n < 0) { close(in); close(out); unlink(dst); return -1; } if (fchmod(out, st.st_mode & 07777) != 0) { /* non-fatal: mode already set at open() */ } close(in); if (close(out) != 0) { unlink(dst); return -1; } unlink(src); return 0; } /* * Byte-compare two files. Returns 1 if identical, 0 otherwise * (including any error — an unreadable file is treated as different, * so the .new is kept and a human decides). */ static int files_identical(const char *a, const char *b) { struct stat sa, sb; FILE *fa, *fb; char ba[8192], bb[8192]; size_t na, nb; int same = 1; if (stat(a, &sa) != 0 || stat(b, &sb) != 0) return 0; if (sa.st_size != sb.st_size) return 0; if ((sa.st_mode & 07777) != (sb.st_mode & 07777)) return 0; fa = fopen(a, "rb"); if (!fa) return 0; fb = fopen(b, "rb"); if (!fb) { fclose(fa); return 0; } do { na = fread(ba, 1, sizeof(ba), fa); nb = fread(bb, 1, sizeof(bb), fb); if (na != nb || memcmp(ba, bb, na) != 0) { same = 0; break; } } while (na > 0); fclose(fa); fclose(fb); return same; } /* ── .new config file handling ───────────────────────────────────────── */ /* * Extract package in two passes for upgrade: * pass 1: extract everything EXCEPT existing etc/ files (keep existing) * pass 2: for each existing etc/ file, extract it as .new * * For a fresh install: single pass, extract everything. */ static int extract_with_etc_handling(const char *pkgfile, const char *root, strarray_t *files, strarray_t *skip, int upgrading) { strarray_t *etc_existing = NULL; size_t i; int hdr_cfg = 0; if (upgrading) { /* collect etc/ files that already exist on disk */ etc_existing = strarray_new(); for (i = 0; i < files->count; i++) { const char *f = files->items[i]; size_t len = strlen(f); char *fp; if (strncmp(f, "etc/", 4) != 0) continue; if (len > 0 && f[len - 1] == '/') continue; fp = path_join(root, f); if (file_exists(fp)) strarray_add(etc_existing, f); free(fp); } /* pass 1: extract with existing etc/ files excluded */ if (etc_existing->count > 0) { strarray_t *combined_skip = strarray_new(); size_t j; /* merge skip list with etc_existing */ if (skip) for (j = 0; j < skip->count; j++) strarray_add(combined_skip, skip->items[j]); for (j = 0; j < etc_existing->count; j++) strarray_add(combined_skip, etc_existing->items[j]); if (arc_extract_pkg(pkgfile, root, NULL, combined_skip) < 0) { strarray_free(combined_skip); strarray_free(etc_existing); return -1; } strarray_free(combined_skip); } else { /* no existing etc/ files — normal extraction */ if (arc_extract_pkg(pkgfile, root, NULL, skip) < 0) { strarray_free(etc_existing); return -1; } } /* * pass 2: extract ONCE into a temp dir, then move each config * file into place as .new. * * This used to run inside the loop — tar has no include-only * mode, so every iteration decompressed and unpacked the entire * package to pick out a single file. A port shipping ten * config files unpacked itself ten times. */ if (etc_existing->count > 0) { const char *tmpdir_env = getenv("TMPDIR"); char tmpdir[PATH_MAX_LEN]; if (!tmpdir_env || !tmpdir_env[0]) tmpdir_env = "/tmp"; snprintf(tmpdir, sizeof(tmpdir), "%s/addpkg.XXXXXX", tmpdir_env); if (mkdtemp(tmpdir)) { arc_extract_members(pkgfile, etc_existing, tmpdir); for (i = 0; i < etc_existing->count; i++) { const char *f = etc_existing->items[i]; char extracted[PATH_MAX_LEN]; char newdest[PATH_MAX_LEN]; char *newdest_dir; snprintf(extracted, sizeof(extracted), "%s/%s", tmpdir, f); snprintf(newdest, sizeof(newdest), "%s/%s.new", root, f); /* ensure destination directory exists */ newdest_dir = path_join(root, f); { char *slash = strrchr(newdest_dir, '/'); if (slash) { *slash = '\0'; mkdirp(newdest_dir, 0755); } } free(newdest_dir); /* remove stale .new and put the new one in place */ unlink(newdest); if (move_file(extracted, newdest) != 0) { warn("cannot stage %s.new", f); continue; } /* * Suppress a .new that is byte-identical to what is * already installed. Staging one per etc/ file on * every upgrade buries the one file that actually * changed under a pile of noise, and a pile nobody * reads is worse than no mechanism at all. */ { char *cur = path_join(root, f); if (files_identical(newdest, cur)) { unlink(newdest); } else { if (!hdr_cfg) { fprintf(stderr, "addpkg: config: staged\n"); hdr_cfg = 1; } fprintf(stderr, "%s.new\n", f); } free(cur); } } rmrf(tmpdir); } } strarray_free(etc_existing); } else { /* fresh install — single pass */ if (arc_extract_pkg(pkgfile, root, NULL, skip) < 0) return -1; } return 0; } /* ── main ────────────────────────────────────────────────────────────── */ int main(int argc, char **argv) { int opt_upgrade = 0, opt_force = 0; const char *opt_root = NULL, *opt_pkg = NULL; const char *root; pkgdb_t *db; pkg_t *existing, *newpkg; char *name, *version; strarray_t *files, *conflicts, *skip, *trigger_files = NULL; pkg_meta_t *meta = NULL; size_t i; int ret = 0; int c; while ((c = getopt(argc, argv, "ufr:vh")) != -1) { switch (c) { case 'u': opt_upgrade = 1; break; case 'f': opt_force = 1; break; case 'r': opt_root = optarg; break; case 'v': printf("addpkg (mkpkg) %s\n", MKPKG_VERSION); return 0; case 'h': usage(); /* FALLTHROUGH */ default: usage(); } } if (optind >= argc) errx(1, "no package specified — try -h"); if (optind < argc - 1) errx(1, "too many arguments"); opt_pkg = argv[optind]; if (getuid() != 0) errx(1, "must be root to install packages"); root = opt_root ? opt_root : (getenv("PKG_ROOT") ? getenv("PKG_ROOT") : "/"); if (arc_list(opt_pkg, &name, &version, &files) < 0) return 1; { int ign_new = load_ignore_new(); if (!opt_force && check_pkg_footprint(opt_pkg, ign_new) != 0) errx(1, "footprint: error"); } db = db_open(root, 1); existing = db_find(db, name); if (existing && !opt_force) { int same = (strcmp(existing->version, version) == 0); if (same) errx(1, "%s %s is already installed (use -f to force)", name, version); if (!opt_upgrade) /* different version installed, no -u given */ errx(1, "%s %s is installed (use -u to upgrade)", name, existing->version); /* different version && opt_upgrade: fall through and proceed */ } if (!existing && opt_upgrade) errx(1, "%s is not installed", name); /* version upgrade: .new for etc/ files * same-version force reinstall: skip etc/ files entirely */ if (existing && !opt_upgrade) { if (strcmp(existing->version, version) != 0) opt_upgrade = 1; /* .new mechanism for etc/ */ /* else: same version -f, we handle etc/ skip below */ } conflicts = find_conflicts(db, name, files); if (conflicts->count > 0 && !opt_force) { error("package %s conflicts with installed files:", name); for (i = 0; i < conflicts->count; i++) fprintf(stderr, " %s\n", conflicts->items[i]); errx(1, "use -f to force"); } skip = strarray_new(); /* same-version force reinstall: skip etc/ files that exist on disk */ if (existing && !opt_upgrade && opt_force) { for (i = 0; i < files->count; i++) { if (strncmp(files->items[i], "etc/", 4) == 0) { char *fullpath = path_join(root, files->items[i]); if (access(fullpath, F_OK) == 0) strarray_add(skip, files->items[i]); /* missing etc/ file: install it */ free(fullpath); } } } /* * Extract package metadata and create system groups BEFORE * extracting files — group ownership must exist for installed files. */ meta = meta_extract(opt_pkg, name); meta_add_groups(meta, root); /* * Extract files. On upgrade, existing etc/ files are preserved and * the new version is placed alongside as .new. * Extract first, remove orphans after — if extraction fails the * old package remains intact. */ if (extract_with_etc_handling(opt_pkg, root, files, skip, opt_upgrade) < 0) { error("extraction failed: %s", opt_pkg); ret = 1; goto cleanup; } /* * Triggers must see paths the upgrade REMOVED as well as paths it * added. Firing only on the new payload means a package that stops * shipping a watched prefix never reconciles: dropping * boot/efi/EFI/BOOT/BOOTX64.EFI from the limine port deleted the * ESP bootloader as an orphan and never ran limine-sync to put it * back, because the new payload had no boot/ path left to match. * * Snapshot before db_remove, which frees `existing`. */ trigger_files = strarray_new(); /* remove files present in old package but absent from new one */ if (existing) { remove_orphans(root, existing->files, files, db, name); for (i = 0; i < existing->files->count; i++) if (!strarray_contains(files, existing->files->items[i])) strarray_add(trigger_files, existing->files->items[i]); /* remove any .new files left from previous upgrades of orphaned paths */ for (i = 0; i < existing->files->count; i++) { const char *f = existing->files->items[i]; if (strncmp(f, "etc/", 4) == 0 && !strarray_contains(files, f)) { char newpath[PATH_MAX_LEN]; char *fp = path_join(root, f); snprintf(newpath, sizeof(newpath), "%s.new", fp); unlink(newpath); free(fp); } } db_remove(db, name); } /* * Build the new db entry from the FULL payload list. * * `skip` holds etc/ files that were deliberately not re-extracted * (same-version -f reinstall preserving local edits). They are * still owned by this package and must stay in the manifest — * dropping them meant a forced reinstall silently orphaned every * config file the package ships, so a later delpkg left them * behind and nothing could ever reclaim them. */ newpkg = pkg_new(name, version); for (i = 0; i < files->count; i++) strarray_add(newpkg->files, files->items[i]); db_add(db, newpkg); if (db_write(db) < 0) { error("extraction succeeded but database write failed — " "package is installed but untracked"); ret = 1; } for (i = 0; i < files->count; i++) strarray_add(trigger_files, files->items[i]); run_ldconfig(root); run_triggers(root, trigger_files, opt_upgrade); strarray_free(trigger_files); trigger_files = NULL; meta_add_services(meta, root); meta_apply_permissions(meta, root); meta_apply_capabilities(meta, root); cleanup: meta_free(meta); strarray_free(files); strarray_free(conflicts); strarray_free(skip); strarray_free(trigger_files); /* NULL-safe; set NULL on the normal path */ free(name); free(version); db_close(db); return ret; }