/* * ports - port collection sync and listing * https://rawnix.org * * Copyright (c) 2025 zorz@gmx.com * SPDX-License-Identifier: BSD-2-Clause */ #include "util.h" #include "mkpkg.h" #include "db.h" #include #include #include #include #include #include #include #include #include #include #include /* ── Constants ───────────────────────────────────────────────────────── */ /* Standard rawnix.org port tree — must match ports.rsync collections */ #define PORTS_LIST_DIRS { \ "/ports/core", \ "/usr/ports/libs", \ "/usr/ports/devel", \ "/usr/ports/python", \ "/usr/ports/perl", \ "/usr/ports/opt" \ } /* ── Configuration structures ────────────────────────────────────────── */ typedef struct { char *name; char *destination; int shared; /* destination is not ours alone: see rsync_config_add() */ } collection_t; typedef struct { char *name; char *host; char *module; char *token; collection_t *collections; size_t count; size_t capacity; } rsync_config_t; typedef struct { char *name; char *url; char *branch; char *destination; } git_config_t; /* ── String helper ───────────────────────────────────────────────────── */ 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; } /* Strip an inline `#` comment from s, in-place. Quote-aware so a * `#` inside "..." or '...' is not treated as a comment marker. */ 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; } } } /* ── Process execution ───────────────────────────────────────────────── */ static int run_command(char *const argv[]) { pid_t pid; int status; pid = fork(); if (pid < 0) { warn("fork"); return -1; } if (pid == 0) { execvp(argv[0], argv); err(127, "%s", argv[0]); } if (waitpid(pid, &status, 0) < 0) { warn("waitpid"); return -1; } return WIFEXITED(status) ? WEXITSTATUS(status) : -1; } /* ── Rsync config ────────────────────────────────────────────────────── */ static rsync_config_t *rsync_config_new(const char *name) { rsync_config_t *c = xmalloc(sizeof(rsync_config_t)); c->name = xstrdup(name); c->host = NULL; c->module = NULL; c->token = NULL; c->collections = NULL; c->count = 0; c->capacity = 0; return c; } static void rsync_config_free(rsync_config_t *c) { size_t i; if (!c) return; free(c->name); free(c->host); free(c->module); free(c->token); for (i = 0; i < c->count; i++) { free(c->collections[i].name); free(c->collections[i].destination); } free(c->collections); free(c); } /* * A collection line is * * name=directory [flag ...] * * and the only flag is "shared": the directory holds more than this * collection's ports. /usr/src is the case that matters -- it also * holds kernel trees, local ports and whatever else the admin keeps * there, and upstream has no idea any of it exists. A shared * directory has nothing removed from its top level, not even an entry * that looks like a port; ports only replaces the port directories * upstream actually ships. * * Without the flag the directory belongs to the collection and ports * is strict about it: a port upstream dropped is removed. "strict" * is accepted so a configuration can say so in as many words. */ static void rsync_config_add(rsync_config_t *c, const char *name, char *value) { char *dest = value, *p; int shared = 0; /* first word is the destination, the rest are flags */ for (p = dest; *p && *p != ' ' && *p != '\t'; p++) ; if (*p) { *p++ = '\0'; while (*p) { char *flag, *end; while (*p == ' ' || *p == '\t') p++; if (!*p) break; flag = p; for (end = p; *end && *end != ' ' && *end != '\t'; end++) ; if (*end) *end++ = '\0'; p = end; if (strcmp(flag, "shared") == 0) shared = 1; else if (strcmp(flag, "strict") == 0) shared = 0; else fprintf(stderr, "ports: %s: unknown flag: %s\n", name, flag); } } if (c->count >= c->capacity) { c->capacity = c->capacity == 0 ? 8 : c->capacity * 2; c->collections = xrealloc(c->collections, c->capacity * sizeof(collection_t)); } c->collections[c->count].name = xstrdup(name); c->collections[c->count].destination = xstrdup(dest); c->collections[c->count].shared = shared; c->count++; } static rsync_config_t *rsync_config_parse(const char *path, const char *name) { FILE *fp; rsync_config_t *c; char line[LINE_MAX_LEN]; char *key, *val, *eq; fp = fopen(path, "r"); if (!fp) { warn("%s", path); return NULL; } c = rsync_config_new(name); 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 = strip_quotes(eq + 1); key = str_trim(key); if (strcmp(key, "host") == 0) { free(c->host); c->host = xstrdup(val); } else if (strcmp(key, "module") == 0) { free(c->module); c->module = xstrdup(val); } else if (strcmp(key, "token") == 0) { free(c->token); c->token = xstrdup(val); } else rsync_config_add(c, key, val); } fclose(fp); if (!c->host || !c->module || c->count == 0) { fprintf(stderr, "ports: incomplete rsync config: %s\n", path); rsync_config_free(c); return NULL; } return c; } /* ── Git config ──────────────────────────────────────────────────────── */ static git_config_t *git_config_parse(const char *path, const char *name) { FILE *fp; git_config_t *c; char line[LINE_MAX_LEN]; char *key, *val, *eq; fp = fopen(path, "r"); if (!fp) { warn("%s", path); return NULL; } c = xmalloc(sizeof(git_config_t)); c->name = xstrdup(name); c->url = NULL; c->branch = NULL; c->destination = NULL; 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 = strip_quotes(eq + 1); key = str_trim(key); if (strcmp(key, "url") == 0) { free(c->url); c->url = xstrdup(val); } else if (strcmp(key, "branch") == 0) { free(c->branch); c->branch = xstrdup(val); } else if (strcmp(key, "destination") == 0) { free(c->destination); c->destination = xstrdup(val); } } fclose(fp); if (!c->url || !c->destination) { fprintf(stderr, "ports: incomplete git config: %s\n", path); free(c->name); free(c->url); free(c->branch); free(c->destination); free(c); return NULL; } if (!c->branch) c->branch = xstrdup("main"); return c; } static void git_config_free(git_config_t *c) { if (!c) return; free(c->name); free(c->url); free(c->branch); free(c->destination); free(c); } /* ── Sync ────────────────────────────────────────────────────────────── */ static int port_recipe(const char *portdir, char *out, size_t outsz); static strarray_t *get_source_filenames(const char *makepkg_path); /* * Write an rsync filter file protecting every file the local ports * downloaded, and return its path in buf. * * The excludes below cover the usual archive names, but a source= entry * is just a URL: it can name a .crate, a .whl, a plain .conf, a * detached signature, anything. Those names match no exclude, so * --delete sees them as files upstream does not have and removes them * -- a sync throwing away a download. Each port's recipe is therefore * read first and the files it names are protected by name. * * "P" only bars deletion: if a port ever ships a file under one of * those names it is still transferred. Nothing here removes a source * either; a source the recipe no longer lists is for ports -xd to * clear out, not for an update to decide. * * Returns 0 and an empty buf when there is nothing to protect. */ static int write_source_filter(const char *dest, char *buf, size_t bufsz) { DIR *dp; struct dirent *de; FILE *fp; int fd, rules = 0; char portdir[PATH_MAX_LEN], recipe[PATH_MAX_LEN]; buf[0] = '\0'; dp = opendir(dest); if (!dp) return 0; /* first sync: nothing here yet */ snprintf(buf, bufsz, "/tmp/ports-filter.XXXXXX"); fd = mkstemp(buf); if (fd < 0) { warn("mkstemp"); buf[0] = '\0'; closedir(dp); return -1; } fp = fdopen(fd, "w"); if (!fp) { warn("fdopen"); close(fd); unlink(buf); buf[0] = '\0'; closedir(dp); return -1; } while ((de = readdir(dp)) != NULL) { strarray_t *sources; size_t i; if (de->d_name[0] == '.') continue; snprintf(portdir, sizeof(portdir), "%s/%s", dest, de->d_name); if (port_recipe(portdir, recipe, sizeof(recipe)) != 0) continue; sources = get_source_filenames(recipe); if (!sources) continue; /* unreadable recipe: excludes still apply */ for (i = 0; i < sources->count; i++) { if (strchr(sources->items[i], '/')) continue; fprintf(fp, "P /%s/%s\n", de->d_name, sources->items[i]); rules++; } strarray_free(sources); } closedir(dp); fclose(fp); if (rules == 0) { unlink(buf); buf[0] = '\0'; } return 0; } /* * Run argv (an rsync --dry-run --itemize-changes) and collect the * top-level names it would delete under dest. For each one that is a * port directory -- it holds a MAKEPKG -- remove it; leave anything * else alone. A locked port (MAKEPKG.lock) is kept and reported. * Best effort: a failed dry run removes nothing. */ static void retire_ports(char *const argv[], const char *dest) { int pipefd[2], status; pid_t pid; FILE *fp; char line[PATH_MAX_LEN]; strarray_t *top = strarray_new(); size_t i; if (pipe(pipefd) < 0) { warn("pipe"); strarray_free(top); return; } pid = fork(); if (pid < 0) { warn("fork"); close(pipefd[0]); close(pipefd[1]); strarray_free(top); return; } if (pid == 0) { int devnull = open("/dev/null", O_WRONLY); dup2(pipefd[1], STDOUT_FILENO); if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } close(pipefd[0]); close(pipefd[1]); execvp(argv[0], argv); _exit(127); } close(pipefd[1]); fp = fdopen(pipefd[0], "r"); if (!fp) { close(pipefd[0]); waitpid(pid, &status, 0); strarray_free(top); return; } while (fgets(line, sizeof(line), fp)) { char *name, *slash; line[strcspn(line, "\n")] = '\0'; if (strncmp(line, "*deleting", 9) != 0) continue; name = line + 9; while (*name == ' ') name++; if (!*name || strcmp(name, "./") == 0) continue; if ((slash = strchr(name, '/')) != NULL) *slash = '\0'; if (!*name || strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; if (!strarray_contains(top, name)) strarray_add(top, name); } fclose(fp); if (waitpid(pid, &status, 0) < 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { strarray_free(top); return; } for (i = 0; i < top->count; i++) { char dir[PATH_MAX_LEN], f[PATH_MAX_LEN]; struct stat st; snprintf(dir, sizeof(dir), "%s/%s", dest, top->items[i]); if (lstat(dir, &st) != 0 || !S_ISDIR(st.st_mode)) continue; snprintf(f, sizeof(f), "%s/MAKEPKG", dir); if (access(f, F_OK) != 0) continue; /* not a port: not ours to remove */ snprintf(f, sizeof(f), "%s/MAKEPKG.lock", dir); if (access(f, F_OK) == 0) { printf("ports: keeping locked port %s (removed upstream)\n", dir); continue; } printf("ports: removing retired port %s\n", dir); if (rmrf(dir) != 0) warn("cannot remove %s", dir); } strarray_free(top); } static int sync_rsync_collection(rsync_config_t *c, collection_t *col) { char source[PATH_MAX_LEN]; char filterfile[PATH_MAX_LEN], filterarg[PATH_MAX_LEN + 8]; char *argv[32]; int argc = 0, base_argc, ret; if (mkdirp(col->destination, 0755) < 0) { warn("mkdirp: %s", col->destination); return -1; } if (c->token && c->token[0]) { setenv("RSYNC_PASSWORD", c->token, 1); snprintf(source, sizeof(source), "%s@%s::%s/%s/", c->token, c->host, c->module, col->name); } else { snprintf(source, sizeof(source), "%s::%s/%s/", c->host, c->module, col->name); } argv[argc++] = "rsync"; argv[argc++] = "-az"; argv[argc++] = "--delete"; /* * The server is an ordinary account (ubuntu, uid 1001), so -a alone * reproduces 1001:1001 on every client that syncs. Those ids mean * nothing here and the tree is root-owned wherever it is used. * * --no-owner/--no-group drop the sender's ids entirely: the files * take the receiving process's own, which is root for a tree under * /usr, and nothing on the client depends on how the server * numbers its accounts. --chown=root:root would also work, but it * implies -o -g and so requires privilege on the receiver, turning * a non-root `ports -u` into a per-file failure rather than one * clean refusal to write a root-owned directory. * * --chmod is applied after -p, so the modes are ours regardless of * the server's umask. A port directory holds recipes, patches and * config; if one ever needs to ship an executable file, the * MAKEPKG installs it with install -m755 rather than rely on the * bit surviving a sync. */ argv[argc++] = "--no-owner"; argv[argc++] = "--no-group"; argv[argc++] = "--chmod=D755,F644"; /* * A locked port's recipe lives beside MAKEPKG as MAKEPKG.lock. * It does not exist upstream, so --delete would remove it on the * next sync; excluding it protects it from deletion as well as * from transfer. One fixed pattern, not one per locked port -- * ports never reads /var/lib/pkg/pkg.lock. */ argv[argc++] = "--exclude=*.lock"; argv[argc++] = "--exclude=*.pkg.tar.*"; argv[argc++] = "--exclude=work/"; argv[argc++] = "--exclude=*.tar.gz"; argv[argc++] = "--exclude=*.tar.xz"; argv[argc++] = "--exclude=*.tar.bz2"; argv[argc++] = "--exclude=*.tar.zst"; argv[argc++] = "--exclude=*.tar.lz"; argv[argc++] = "--exclude=*.zip"; /* * ...and the downloads those patterns do not describe, protected * by the names the recipes give them. */ write_source_filter(col->destination, filterfile, sizeof(filterfile)); if (filterfile[0]) { snprintf(filterarg, sizeof(filterarg), "--filter=merge %s", filterfile); argv[argc++] = filterarg; } /* * --delete is scoped to the ports themselves. A collection's * destination is not necessarily a directory ports owns: linux= * syncs into /usr/src, which also holds kernel trees and whatever * else the admin keeps there, and a plain --delete at the top of * the collection would wipe all of it. * * So the real sync protects every top-level entry (a "P" filter * rule): inside a port directory that exists upstream, --delete * still removes files upstream dropped, but nothing at the top * level is ever deleted by rsync. * * In a directory the collection owns, a port upstream retired has * to go, and it is found afterwards with a dry run without that * rule; retire_ports() removes it only if it is a port directory * (it holds a MAKEPKG) -- never a directory ports did not create, * never a locked port. That test cannot tell a port upstream * dropped from one that was never upstream to begin with, so in a * shared directory the dry run is not run at all: a hand-written * port of your own, say a kernel beside the ones the collection * ships, is not upstream's to retire. */ base_argc = argc; argv[argc++] = "--quiet"; argv[argc++] = "--filter=P /*"; argv[argc++] = source; argv[argc++] = col->destination; argv[argc] = NULL; printf("ports: syncing %s -> %s\n", col->name, col->destination); ret = run_command(argv); if (ret != 0) { if (filterfile[0]) unlink(filterfile); return ret; } if (!col->shared) { argc = base_argc; argv[argc++] = "--dry-run"; argv[argc++] = "--itemize-changes"; argv[argc++] = source; argv[argc++] = col->destination; argv[argc] = NULL; retire_ports(argv, col->destination); } if (filterfile[0]) unlink(filterfile); return 0; } static int name_in_filter(const char *name, char **filter, int count) { int i; if (count == 0) return 1; for (i = 0; i < count; i++) if (strcmp(name, filter[i]) == 0) return 1; return 0; } static int sync_rsync(rsync_config_t *c, char **col_filter, int col_count) { size_t i; int failed = 0, synced = 0; printf("ports: updating %s (%s::%s)\n", c->name, c->host, c->module); for (i = 0; i < c->count; i++) { if (col_count > 0 && !name_in_filter(c->collections[i].name, col_filter, col_count)) continue; if (sync_rsync_collection(c, &c->collections[i]) != 0) { fprintf(stderr, "ports: failed to sync %s\n", c->collections[i].name); failed++; } else { synced++; } } if (col_count > 0 && synced == 0 && failed == 0) return 1; /* filter matched nothing in this config */ return failed > 0 ? 1 : 0; } static int sync_git(git_config_t *c) { char gitdir[PATH_MAX_LEN]; struct stat st; printf("ports: updating %s (%s)\n", c->name, c->url); snprintf(gitdir, sizeof(gitdir), "%s/.git", c->destination); if (stat(gitdir, &st) == 0 && S_ISDIR(st.st_mode)) { char *argv[] = { "git", "-C", c->destination, "pull", "--ff-only", NULL }; return run_command(argv); } else { char *parent, *slash; parent = xstrdup(c->destination); slash = strrchr(parent, '/'); if (slash) { *slash = '\0'; mkdirp(parent, 0755); } free(parent); { char *argv[] = { "git", "clone", "-b", c->branch, c->url, c->destination, NULL }; return run_command(argv); } } } /* ── Config file discovery ───────────────────────────────────────────── */ static char *match_extension(const char *filename, const char *ext) { size_t flen = strlen(filename), elen = strlen(ext); char *name; if (flen <= elen) return NULL; if (strcmp(filename + flen - elen, ext) != 0) return NULL; name = xstrdup(filename); name[flen - elen] = '\0'; return name; } /* ── Commands: update / list ─────────────────────────────────────────── */ static int cmd_update(char **filter, int filter_count) { DIR *dp; struct dirent *de; int failed = 0, synced = 0; char path[PATH_MAX_LEN]; char *name; if (getuid() != 0) errx(1, "only root can update ports"); dp = opendir(PORTS_CONF_DIR); if (!dp) err(1, "%s", PORTS_CONF_DIR); while ((de = readdir(dp)) != NULL) { name = match_extension(de->d_name, ".rsync"); if (name) { rsync_config_t *c; char **col_filter; int col_count; snprintf(path, sizeof(path), "%s/%s", PORTS_CONF_DIR, de->d_name); c = rsync_config_parse(path, name); if (c) { if (filter_count == 0) { /* no filter — sync all collections */ col_filter = NULL; col_count = 0; } else if (name_in_filter(name, filter, filter_count)) { /* config name matched — sync all its collections */ col_filter = NULL; col_count = 0; } else { /* treat filter as collection names within any config */ col_filter = filter; col_count = filter_count; } if (sync_rsync(c, col_filter, col_count) == 0) synced++; else failed++; rsync_config_free(c); } else failed++; free(name); continue; } name = match_extension(de->d_name, ".git"); if (name) { if (name_in_filter(name, filter, filter_count)) { git_config_t *c; snprintf(path, sizeof(path), "%s/%s", PORTS_CONF_DIR, de->d_name); c = git_config_parse(path, name); if (c) { if (sync_git(c) != 0) failed++; else synced++; git_config_free(c); } else failed++; } free(name); continue; } } closedir(dp); if (synced == 0 && failed == 0) { if (filter_count > 0) fprintf(stderr, "ports: no matching collections found\n"); else fprintf(stderr, "ports: no port configs in %s\n", PORTS_CONF_DIR); return 1; } printf("ports: %d synced", synced); if (failed > 0) printf(", %d failed", failed); printf("\n"); return failed > 0 ? 1 : 0; } /* * The collections -l, -d and -x work on: every collection configured * in /etc/ports (rsync collections by their key, git configs by their * file name), then every directory in /usr/local/ports. Only if no * configuration is active are the standard rawnix.org directories used. */ typedef struct { char *name; char *dir; } coll_t; static coll_t *g_colls = NULL; static size_t g_ncolls = 0; static void coll_add(const char *name, const char *dir) { size_t i; for (i = 0; i < g_ncolls; i++) if (strcmp(g_colls[i].dir, dir) == 0) return; g_colls = xrealloc(g_colls, (g_ncolls + 1) * sizeof(coll_t)); g_colls[g_ncolls].name = xstrdup(name); g_colls[g_ncolls].dir = xstrdup(dir); g_ncolls++; } static void load_collections(void) { static const char *std_dirs[] = PORTS_LIST_DIRS; DIR *dp; struct dirent *de; struct stat st; char path[PATH_MAX_LEN]; size_t i; dp = opendir(PORTS_CONF_DIR); if (dp) { while ((de = readdir(dp)) != NULL) { char *name; if ((name = match_extension(de->d_name, ".rsync")) != NULL) { rsync_config_t *c; snprintf(path, sizeof(path), "%s/%s", PORTS_CONF_DIR, de->d_name); c = rsync_config_parse(path, name); if (c) { for (i = 0; i < c->count; i++) coll_add(c->collections[i].name, c->collections[i].destination); rsync_config_free(c); } free(name); } else if ((name = match_extension(de->d_name, ".git")) != NULL) { git_config_t *g; snprintf(path, sizeof(path), "%s/%s", PORTS_CONF_DIR, de->d_name); g = git_config_parse(path, name); if (g) { coll_add(g->name, g->destination); git_config_free(g); } free(name); } } closedir(dp); } if (g_ncolls == 0) for (i = 0; i < sizeof(std_dirs) / sizeof(std_dirs[0]); i++) { const char *b = strrchr(std_dirs[i], '/'); coll_add(b ? b + 1 : std_dirs[i], std_dirs[i]); } dp = opendir("/usr/local/ports"); if (dp) { while ((de = readdir(dp)) != NULL) { if (de->d_name[0] == '.') continue; snprintf(path, sizeof(path), "/usr/local/ports/%s", de->d_name); if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) coll_add(de->d_name, path); } closedir(dp); } } /* collection names given on the command line restrict -l, -d and -x */ static int coll_selected(const coll_t *c, char **filter, int count) { int i; if (count == 0) return 1; for (i = 0; i < count; i++) if (strcmp(c->name, filter[i]) == 0) return 1; return 0; } /* * The recipe a port is built from: MAKEPKG.lock when the port is * locked, MAKEPKG otherwise -- the same choice mkpkg makes. Returns 0 * if the directory is a port. */ static int port_recipe(const char *portdir, char *out, size_t outsz) { struct stat st; snprintf(out, outsz, "%s/MAKEPKG.lock", portdir); if (stat(out, &st) == 0 && S_ISREG(st.st_mode)) return 0; snprintf(out, outsz, "%s/MAKEPKG", portdir); if (stat(out, &st) == 0 && S_ISREG(st.st_mode)) return 0; return -1; } static int cmd_list(char **filter, int count) { DIR *dp; struct dirent *de; char portdir[PATH_MAX_LEN], recipe[PATH_MAX_LEN]; size_t i; int found = 0; load_collections(); for (i = 0; i < g_ncolls; i++) { if (!coll_selected(&g_colls[i], filter, count)) continue; if (!(dp = opendir(g_colls[i].dir))) continue; while ((de = readdir(dp)) != NULL) { if (de->d_name[0] == '.') continue; snprintf(portdir, sizeof(portdir), "%s/%s", g_colls[i].dir, de->d_name); if (port_recipe(portdir, recipe, sizeof(recipe)) != 0) continue; printf("%s/%s\n", g_colls[i].name, de->d_name); found++; } closedir(dp); } if (!found) printf("ports: no ports found.\n"); return 0; } /* ── Clean helpers ───────────────────────────────────────────────────── */ /* * Parse name=, version=, release= from MAKEPKG without a shell. * Only handles plain assignments — sufficient since these three * fields are always literals in rawnix.org ports. */ static int parse_makepkg_nvr(const char *makepkg_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; fp = fopen(makepkg_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 == '\0') continue; if (strncmp(p, "build()", 7) == 0) break; 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; } /* * Get source basenames from MAKEPKG using our parser. * Handles renames, variable expansion, URL stripping. */ static strarray_t *get_source_filenames(const char *makepkg_path) { pkgfile_t pf; strarray_t *out; int i; if (parse_pkgfile(makepkg_path, &pf) != 0) return NULL; out = strarray_new(); for (i = 0; i < pf.source_count; i++) { const char *url = pf.sources[i].url; const char *rename = pf.sources[i].rename; const char *base; char tmp[PATH_MAX_LEN]; if (rename && rename[0] && strcmp(rename, "SKIP") != 0) { strarray_add(out, rename); continue; } /* strip URL path to get basename */ base = strrchr(url, '/'); base = base ? base + 1 : url; /* strip query string if present */ snprintf(tmp, sizeof(tmp), "%s", base); { char *q = strchr(tmp, '?'); if (q) *q = '\0'; } if (tmp[0]) strarray_add(out, tmp); } free_pkgfile(&pf); return out; } /* ── Per-port clean operations ───────────────────────────────────────── */ static int is_source_archive(const char *name) { static const char *exts[] = { ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tar.lz", ".tar.lzma", ".tgz", ".tbz2", ".zip", ".tar", ".7z", NULL }; size_t nlen = strlen(name), i; for (i = 0; exts[i]; i++) { size_t elen = strlen(exts[i]); if (nlen > elen && strcmp(name + nlen - elen, exts[i]) == 0) return 1; } return 0; } static int is_pkg_file(const char *name) { return strstr(name, PKG_EXT) != NULL; } static void clean_pkg(const char *portdir, const char *makepkg_path) { char name[256], version[256], release[64], keep_prefix[PATH_MAX_LEN]; char filepath[PATH_MAX_LEN]; DIR *dp; struct dirent *de; if (parse_makepkg_nvr(makepkg_path, name, sizeof(name), version, sizeof(version), release, sizeof(release)) < 0) { fprintf(stderr, "ports: cannot parse %s\n", makepkg_path); return; } snprintf(keep_prefix, sizeof(keep_prefix), "%s#%s-%s", name, version, release); dp = opendir(portdir); if (!dp) return; while ((de = readdir(dp)) != NULL) { if (!is_pkg_file(de->d_name)) continue; if (strncmp(de->d_name, keep_prefix, strlen(keep_prefix)) == 0) continue; snprintf(filepath, sizeof(filepath), "%s/%s", portdir, de->d_name); printf(" removing %s\n", filepath); unlink(filepath); } closedir(dp); } static void clean_src(const char *portdir, const char *makepkg_path) { strarray_t *keep; DIR *dp; struct dirent *de; char filepath[PATH_MAX_LEN]; keep = get_source_filenames(makepkg_path); if (!keep) { fprintf(stderr, "ports: cannot read source=() from %s\n", makepkg_path); return; } dp = opendir(portdir); if (!dp) { strarray_free(keep); return; } while ((de = readdir(dp)) != NULL) { if (is_pkg_file(de->d_name)) continue; if (!is_source_archive(de->d_name)) continue; if (strarray_contains(keep, de->d_name)) continue; snprintf(filepath, sizeof(filepath), "%s/%s", portdir, de->d_name); printf(" removing %s\n", filepath); unlink(filepath); } closedir(dp); strarray_free(keep); } static void clean_dotfile(const char *portdir, const char *filename) { char filepath[PATH_MAX_LEN]; snprintf(filepath, sizeof(filepath), "%s/%s", portdir, filename); if (access(filepath, F_OK) == 0) { printf(" removing %s\n", filepath); unlink(filepath); } } /* ── Directory walker ────────────────────────────────────────────────── */ #define CLEAN_PKG (1 << 0) /* -x: remove stale package files */ #define CLEAN_SRC (1 << 1) /* -xd: remove old source archives */ #define CLEAN_FP (1 << 2) /* -xf: remove .footprint */ #define CLEAN_SIG (1 << 3) /* -xs: remove .signature */ #define CLEAN_WORK (1 << 4) /* -xw: remove work/ directory */ static void walk_collection(const char *colldir, int mode) { DIR *dp; struct dirent *de; char portdir[PATH_MAX_LEN], recipe[PATH_MAX_LEN]; dp = opendir(colldir); if (!dp) return; while ((de = readdir(dp)) != NULL) { if (de->d_name[0] == '.') continue; snprintf(portdir, sizeof(portdir), "%s/%s", colldir, de->d_name); if (port_recipe(portdir, recipe, sizeof(recipe)) != 0) continue; printf("%s:\n", portdir); /* package and sources are judged against the recipe the port * is actually built from, so a locked port keeps its own */ if (mode & CLEAN_PKG) clean_pkg(portdir, recipe); if (mode & CLEAN_SRC) clean_src(portdir, recipe); if (mode & CLEAN_FP) clean_dotfile(portdir, ".footprint"); if (mode & CLEAN_SIG) clean_dotfile(portdir, ".signature"); if (mode & CLEAN_WORK) { char workdir[PATH_MAX_LEN]; snprintf(workdir, sizeof(workdir), "%s/work", portdir); if (access(workdir, F_OK) == 0) { printf(" removing %s\n", workdir); rmrf(workdir); } } } closedir(dp); } static int cmd_clean(int mode, char **filter, int count) { size_t i; if (getuid() != 0) errx(1, "only root can clean ports"); load_collections(); for (i = 0; i < g_ncolls; i++) if (coll_selected(&g_colls[i], filter, count)) walk_collection(g_colls[i].dir, mode); return 0; } /* ── Diff: port tree vs installed ────────────────────────────────────── */ static int cmd_diff(char **filter, int count) { pkgdb_t *db; DIR *dp; struct dirent *de; char portdir[PATH_MAX_LEN], recipe[PATH_MAX_LEN]; size_t i; int found = 0; db = db_open("/", 0); if (!db) { fprintf(stderr, "ports: cannot open package db\n"); return 1; } load_collections(); for (i = 0; i < g_ncolls; i++) { if (!coll_selected(&g_colls[i], filter, count)) continue; if (!(dp = opendir(g_colls[i].dir))) continue; while ((de = readdir(dp)) != NULL) { char name[256], version[256], release[64]; char port_ver[512]; pkg_t *installed; if (de->d_name[0] == '.') continue; snprintf(portdir, sizeof(portdir), "%s/%s", g_colls[i].dir, de->d_name); if (port_recipe(portdir, recipe, sizeof(recipe)) != 0) continue; if (parse_makepkg_nvr(recipe, name, sizeof(name), version, sizeof(version), release, sizeof(release)) < 0) continue; installed = db_find(db, name); if (!installed) continue; /* not installed — skip */ snprintf(port_ver, sizeof(port_ver), "%s-%s", version, release); if (strcmp(installed->version, port_ver) == 0) continue; printf("%s/%s: installed %s, port %s\n", g_colls[i].name, name, installed->version, port_ver); found++; } closedir(dp); } db_close(db); if (!found) printf("ports: all ports up to date\n"); return 0; } /* ── Usage / flag parsing / main ─────────────────────────────────────── */ static void usage(void) { printf( "usage: ports [options] [collection ...]\n" " -u update port collections\n" " -l list ports\n" " -d diff port tree vs installed\n" " -x [dfsw] clean\n" " -v / -h version / help\n" ); } /* * Parse combined flags: -u, -l, -x [dfs] * -x alone: remove stale package files * -xd: also remove old source archives * -xf: also remove .footprint * -xs: also remove .signature */ static void parse_flags(const char *arg, int *mode_update, int *mode_list, int *mode_diff, int *clean_mask) { const char *p; int in_x = 0; for (p = arg; *p; p++) { switch (*p) { case 'x': in_x = 1; *clean_mask |= CLEAN_PKG; /* -x always cleans pkg files */ break; case 'u': if (!in_x) *mode_update = 1; break; case 'l': if (!in_x) *mode_list = 1; break; case 'd': if (!in_x) *mode_diff = 1; else *clean_mask |= CLEAN_SRC; break; case 'f': if (in_x) *clean_mask |= CLEAN_FP; break; case 's': if (in_x) *clean_mask |= CLEAN_SIG; break; case 'w': if (in_x) *clean_mask |= CLEAN_WORK; break; case 'v': printf("ports (mkpkg) %s\n", MKPKG_VERSION); exit(0); case 'h': usage(); exit(0); default: if (!in_x) { fprintf(stderr, "ports: unknown option: -%c\n", *p); exit(1); } } } } int main(int argc, char **argv) { int mode_update = 0, mode_list = 0, mode_diff = 0, clean_mask = 0; int i; char *collections[64]; int ncol = 0; if (argc < 2) { usage(); return 1; } for (i = 1; i < argc; i++) { if (strcmp(argv[i], "--help") == 0) { usage(); return 0; } if (strcmp(argv[i], "--version") == 0) { printf("ports (mkpkg) %s\n", MKPKG_VERSION); return 0; } if (argv[i][0] == '-') { parse_flags(argv[i] + 1, &mode_update, &mode_list, &mode_diff, &clean_mask); } else { if (ncol < 64) collections[ncol++] = argv[i]; } } if (mode_update) return cmd_update(collections, ncol); if (mode_list) return cmd_list(collections, ncol); if (mode_diff) return cmd_diff(collections, ncol); if (clean_mask) return cmd_clean(clean_mask, collections, ncol); fprintf(stderr, "ports: option required\n"); fprintf(stderr, "Try 'ports -h' for more information.\n"); return 1; }