git: 81d8827ad875 - main - certctl: Reimplement in C

From: Dag-Erling Smørgrav <des_at_FreeBSD.org>
Date: Wed, 13 Aug 2025 22:25:47 UTC
The branch main has been updated by des:

URL: https://cgit.FreeBSD.org/src/commit/?id=81d8827ad8752e35411204541f1f09df1481e417

commit 81d8827ad8752e35411204541f1f09df1481e417
Author:     Dag-Erling Smørgrav <des@FreeBSD.org>
AuthorDate: 2025-08-13 22:25:27 +0000
Commit:     Dag-Erling Smørgrav <des@FreeBSD.org>
CommitDate: 2025-08-13 22:25:27 +0000

    certctl: Reimplement in C
    
    Notable changes include:
    
    * We no longer forget manually untrusted certificates when rehashing.
    
    * Rehash will now scan the existing directory and progressively replace
      its contents with those of the new trust store.  The trust store as a
      whole is not replaced atomically, but each file within it is.
    
    * We no longer attempt to link to the original files, but we don't copy
      them either.  Instead, we write each certificate out in its minimal
      form.
    
    * We now generate a trust bundle in addition to the hashed diretory.
      This also contains only the minimal DER form of each certificate.
    
    * The C version is approximately two orders of magnitude faster than the
      sh version, with rehash taking ~100 ms vs ~5-25 s depending on whether
      ca_root_nss is installed.
    
    * The DISTBASE concept has been dropped; the same effect can be achieved
      by adjusting DESTDIR.
    
    * We now also have rudimentary tests.
    
    Reviewed by:    kevans
    Differential Revision:  https://reviews.freebsd.org/D42320
---
 Makefile.inc1                          |   21 +-
 usr.sbin/certctl/Makefile              |    7 +-
 usr.sbin/certctl/certctl.8             |   94 +--
 usr.sbin/certctl/certctl.c             | 1060 ++++++++++++++++++++++++++++++++
 usr.sbin/certctl/certctl.sh            |  366 -----------
 usr.sbin/certctl/tests/Makefile        |    5 +
 usr.sbin/certctl/tests/certctl.subr    |   44 ++
 usr.sbin/certctl/tests/certctl_test.sh |  221 +++++++
 8 files changed, 1404 insertions(+), 414 deletions(-)

diff --git a/Makefile.inc1 b/Makefile.inc1
index 9128d1d8ee77..e67bc7f5d1b1 100644
--- a/Makefile.inc1
+++ b/Makefile.inc1
@@ -1021,8 +1021,7 @@ IMAKE_MTREE=	MTREE_CMD="${MTREE_CMD} ${MTREEFLAGS}"
 .endif
 
 .if make(distributeworld)
-CERTCTLDESTDIR=	${DESTDIR}/${DISTDIR}
-CERTCTLFLAGS+=	-d /base
+CERTCTLDESTDIR=	${DESTDIR}/${DISTDIR}/base
 .else
 CERTCTLDESTDIR=	${DESTDIR}
 .endif
@@ -1541,14 +1540,10 @@ distributeworld installworld stageworld: _installcheck_world .PHONY
 .endif # make(distributeworld)
 	${_+_}cd ${.CURDIR}; ${IMAKE} re${.TARGET:S/world$//}; \
 	    ${IMAKEENV} rm -rf ${INSTALLTMP}
-.if !make(packageworld) && ${MK_CAROOT} != "no"
-	@if which openssl>/dev/null; then \
-		PATH=${TMPPATH:Q}:${PATH:Q} \
-		LOCALBASE=${LOCALBASE:Q} \
-		    sh ${SRCTOP}/usr.sbin/certctl/certctl.sh ${CERTCTLFLAGS} rehash; \
-	else \
-		echo "No openssl on the host, not rehashing certificates target -- /etc/ssl may not be populated."; \
-	fi
+.if !make(packageworld) && ${MK_CAROOT} != "no" && ${MK_OPENSSL} != "no"
+	PATH=${TMPPATH:Q}:${PATH:Q} \
+	LOCALBASE=${LOCALBASE:Q} \
+	    certctl ${CERTCTLFLAGS} rehash
 .endif
 .if make(distributeworld)
 .for dist in ${EXTRA_DISTRIBUTIONS}
@@ -2712,6 +2707,11 @@ _basic_bootstrap_tools+=sbin/md5
 _basic_bootstrap_tools+=usr.sbin/tzsetup
 .endif
 
+# certctl is needed as an install tool
+.if ${MK_CAROOT} != "no" && ${MK_OPENSSL} != "no"
+_certctl=usr.sbin/certctl
+.endif
+
 .if defined(BOOTSTRAP_ALL_TOOLS)
 _other_bootstrap_tools+=${_basic_bootstrap_tools}
 .for _subdir _links in ${_basic_bootstrap_tools_multilink}
@@ -2775,6 +2775,7 @@ bootstrap-tools: ${_bt}-links .PHONY
     ${_strfile} \
     usr.bin/dtc \
     ${_cat} \
+    ${_certctl} \
     ${_kbdcontrol} \
     ${_elftoolchain_libs} \
     ${_libkldelf} \
diff --git a/usr.sbin/certctl/Makefile b/usr.sbin/certctl/Makefile
index 88c024daf7e6..5430dbf24853 100644
--- a/usr.sbin/certctl/Makefile
+++ b/usr.sbin/certctl/Makefile
@@ -1,5 +1,10 @@
+.include <src.opts.mk>
+
 PACKAGE=	certctl
-SCRIPTS=certctl.sh
+PROG=	certctl
 MAN=	certctl.8
+LIBADD=	crypto
+HAS_TESTS=
+SUBDIR.${MK_TESTS}=	tests
 
 .include <bsd.prog.mk>
diff --git a/usr.sbin/certctl/certctl.8 b/usr.sbin/certctl/certctl.8
index 7e49bb89e2ac..97bdc840c359 100644
--- a/usr.sbin/certctl/certctl.8
+++ b/usr.sbin/certctl/certctl.8
@@ -24,7 +24,7 @@
 .\" IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 .\" POSSIBILITY OF SUCH DAMAGE.
 .\"
-.Dd July 17, 2025
+.Dd August 11, 2025
 .Dt CERTCTL 8
 .Os
 .Sh NAME
@@ -32,63 +32,83 @@
 .Nd "tool for managing trusted and untrusted TLS certificates"
 .Sh SYNOPSIS
 .Nm
-.Op Fl v
+.Op Fl lv
 .Ic list
 .Nm
-.Op Fl v
+.Op Fl lv
 .Ic untrusted
 .Nm
-.Op Fl cnUv
+.Op Fl BnUv
 .Op Fl D Ar destdir
 .Op Fl M Ar metalog
 .Ic rehash
 .Nm
-.Op Fl cnv
-.Ic untrust Ar file
+.Op Fl nv
+.Ic untrust Ar
 .Nm
-.Op Fl cnv
-.Ic trust Ar file
+.Op Fl nv
+.Ic trust Ar
 .Sh DESCRIPTION
 The
 .Nm
 utility manages the list of TLS Certificate Authorities that are trusted by
 applications that use OpenSSL.
 .Pp
-Flags:
+The following options are available:
 .Bl -tag -width 4n
-.It Fl c
-Copy certificates instead of linking to them.
+.It Fl B
+Do not generate a bundle.
+This option is only valid in conjunction with the
+.Ic rehash
+command.
 .It Fl D Ar destdir
 Specify the DESTDIR (overriding values from the environment).
-.It Fl d Ar distbase
-Specify the DISTBASE (overriding values from the environment).
+.It Fl l
+When listing installed (trusted or untrusted) certificates, show the
+full path and distinguished name for each certificate.
 .It Fl M Ar metalog
-Specify the path of the METALOG file (default: $DESTDIR/METALOG).
+Specify the path of the METALOG file
+.Po
+default:
+.Pa ${DESTDIR}/METALOG
+.Pc .
+This option is only valid in conjunction with the
+.Ic rehash
+command.
 .It Fl n
-No-Op mode, do not actually perform any actions.
+Dry-run mode.
+Do not actually perform any actions except write the metalog.
 .It Fl v
-Be verbose, print details about actions before performing them.
+Verbose mode.
+Print detailed information about each action taken.
 .It Fl U
-Unprivileged mode, do not change the ownership of created links.
-Do record the ownership in the METALOG file.
+Unprivileged mode.
+Do not attempt to set the ownership of created files.
+This option is only valid in conjunction with the
+.Fl M
+option and the
+.Ic rehash
+command.
 .El
 .Pp
 Primary command functions:
 .Bl -tag -width untrusted
 .It Ic list
-List all currently trusted certificate authorities.
+List all currently trusted certificates.
 .It Ic untrusted
 List all currently untrusted certificates.
 .It Ic rehash
-Rebuild the list of trusted certificate authorities by scanning all directories
+Rebuild the list of trusted certificates by scanning all directories
 in
 .Ev TRUSTPATH
 and all untrusted certificates in
 .Ev UNTRUSTPATH .
-A symbolic link to each trusted certificate is placed in
+A copy of each trusted certificate is placed in
 .Ev CERTDESTDIR
 and each untrusted certificate in
 .Ev UNTRUSTDESTDIR .
+In addition, a bundle containing the trusted certificates is placed in
+.Ev BUNDLEFILE .
 .It Ic untrust
 Add the specified file to the untrusted list.
 .It Ic trust
@@ -98,8 +118,6 @@ Remove the specified file from the untrusted list.
 .Bl -tag -width UNTRUSTDESTDIR
 .It Ev DESTDIR
 Alternate destination directory to operate on.
-.It Ev DISTBASE
-Additional path component to include when operating on certificate directories.
 .It Ev LOCALBASE
 Location for local programs.
 Defaults to the value of the user.localbase sysctl which is usually
@@ -107,32 +125,34 @@ Defaults to the value of the user.localbase sysctl which is usually
 .It Ev TRUSTPATH
 List of paths to search for trusted certificates.
 Default:
-.Pa <DESTDIR><DISTBASE>/usr/share/certs/trusted
-.Pa <DESTDIR><DISTBASE>/usr/local/share/certs
-.Pa <DESTDIR><DISTBASE><LOCALBASE>/etc/ssl/certs
+.Pa ${DESTDIR}/usr/share/certs/trusted
+.Pa ${DESTDIR}${LOCALBASE}/share/certs/trusted
+.Pa ${DESTDIR}${LOCALBASE}/share/certs
 .It Ev UNTRUSTPATH
 List of paths to search for untrusted certificates.
 Default:
-.Pa <DESTDIR><DISTBASE>/usr/share/certs/untrusted
-.Pa <DESTDIR><DISTBASE><LOCALBASE>/etc/ssl/untrusted
-.Pa <DESTDIR><DISTBASE><LOCALBASE>/etc/ssl/blacklisted
-.It Ev CERTDESTDIR
+.Pa ${DESTDIR}/usr/share/certs/untrusted
+.Pa ${DESTDIR}${LOCALBASE}/share/certs/untrusted
+.It Ev TRUSTDESTDIR
 Destination directory for symbolic links to trusted certificates.
 Default:
-.Pa <DESTDIR><DISTBASE>/etc/ssl/certs
+.Pa ${DESTDIR}/etc/ssl/certs
 .It Ev UNTRUSTDESTDIR
 Destination directory for symbolic links to untrusted certificates.
 Default:
-.Pa <DESTDIR><DISTBASE>/etc/ssl/untrusted
-.It Ev EXTENSIONS
-List of file extensions to read as certificate files.
-Default: *.pem *.crt *.cer *.crl *.0
+.Pa ${DESTDIR}/etc/ssl/untrusted
+.It Ev BUNDLE
+File name of bundle to produce.
 .El
 .Sh SEE ALSO
 .Xr openssl 1
 .Sh HISTORY
 .Nm
 first appeared in
-.Fx 12.2
+.Fx 12.2 .
 .Sh AUTHORS
-.An Allan Jude Aq Mt allanjude@freebsd.org
+.An -nosplit
+The original shell implementation was written by
+.An Allan Jude Aq Mt allanjude@FreeBSD.org .
+The current C implementation was written by
+.An Dag-Erling Sm\(/orgrav Aq Mt des@FreeBSD.org .
diff --git a/usr.sbin/certctl/certctl.c b/usr.sbin/certctl/certctl.c
new file mode 100644
index 000000000000..6687e56f23b4
--- /dev/null
+++ b/usr.sbin/certctl/certctl.c
@@ -0,0 +1,1060 @@
+/*-
+ * Copyright (c) 2023-2025 Dag-Erling Smørgrav <des@FreeBSD.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#include <sys/sysctl.h>
+#include <sys/stat.h>
+#include <sys/tree.h>
+
+#include <dirent.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <fts.h>
+#include <paths.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <openssl/ssl.h>
+
+#define info(fmt, ...)							\
+	do {								\
+		if (verbose)						\
+			fprintf(stderr, fmt "\n", ##__VA_ARGS__);	\
+	} while (0)
+
+static char *
+xasprintf(const char *fmt, ...)
+{
+	va_list ap;
+	char *str;
+	int ret;
+
+	va_start(ap, fmt);
+	ret = vasprintf(&str, fmt, ap);
+	va_end(ap);
+	if (ret < 0 || str == NULL)
+		err(1, NULL);
+	return (str);
+}
+
+static char *
+xstrdup(const char *str)
+{
+	char *dup;
+
+	if ((dup = strdup(str)) == NULL)
+		err(1, NULL);
+	return (dup);
+}
+
+static void usage(void);
+
+static bool dryrun;
+static bool longnames;
+static bool nobundle;
+static bool unprivileged;
+static bool verbose;
+
+static const char *localbase;
+static const char *destdir;
+static const char *metalog;
+
+static const char *uname = "root";
+static const char *gname = "wheel";
+
+static const char *const default_trusted_paths[] = {
+	"/usr/share/certs/trusted",
+	"%L/share/certs/trusted",
+	"%L/share/certs",
+	NULL
+};
+static char **trusted_paths;
+
+static const char *const default_untrusted_paths[] = {
+	"/usr/share/certs/untrusted",
+	"%L/share/certs/untrusted",
+	NULL
+};
+static char **untrusted_paths;
+
+static char *trusted_dest;
+static char *untrusted_dest;
+static char *bundle_dest;
+
+#define SSL_PATH		"/etc/ssl"
+#define TRUSTED_DIR		"certs"
+#define TRUSTED_PATH		SSL_PATH "/" TRUSTED_DIR
+#define UNTRUSTED_DIR		"untrusted"
+#define UNTRUSTED_PATH		SSL_PATH "/" UNTRUSTED_DIR
+#define LEGACY_DIR		"blacklisted"
+#define LEGACY_PATH		SSL_PATH "/" LEGACY_DIR
+#define BUNDLE_FILE		"cert.pem"
+#define BUNDLE_PATH		SSL_PATH "/" BUNDLE_FILE
+
+static FILE *mlf;
+
+/*
+ * Split a colon-separated list into a NULL-terminated array.
+ */
+static char **
+split_paths(const char *str)
+{
+	char **paths;
+	const char *p, *q;
+	unsigned int i, n;
+
+	for (p = str, n = 1; *p; p++) {
+		if (*p == ':')
+			n++;
+	}
+	if ((paths = calloc(n + 1, sizeof(*paths))) == NULL)
+		err(1, NULL);
+	for (p = q = str, i = 0; i < n; i++, p = q + 1) {
+		q = strchrnul(p, ':');
+		if ((paths[i] = strndup(p, q - p)) == NULL)
+			err(1, NULL);
+	}
+	return (paths);
+}
+
+/*
+ * Expand %L into LOCALBASE and prefix DESTDIR.
+ */
+static char *
+expand_path(const char *template)
+{
+	if (template[0] == '%' && template[1] == 'L')
+		return (xasprintf("%s%s%s", destdir, localbase, template + 2));
+	return (xasprintf("%s%s", destdir, template));
+}
+
+/*
+ * Expand an array of paths.
+ */
+static char **
+expand_paths(const char *const *templates)
+{
+	char **paths;
+	unsigned int i, n;
+
+	for (n = 0; templates[n] != NULL; n++)
+		continue;
+	if ((paths = calloc(n + 1, sizeof(*paths))) == NULL)
+		err(1, NULL);
+	for (i = 0; i < n; i++)
+		paths[i] = expand_path(templates[i]);
+	return (paths);
+}
+
+/*
+ * If destdir is a prefix of path, returns a pointer to the rest of path,
+ * otherwise returns path.
+ */
+static const char *
+unexpand_path(const char *path)
+{
+	const char *p = path;
+	const char *q = destdir;
+
+	while (*p && *p == *q) {
+		p++;
+		q++;
+	}
+	return (*q == '\0' && *p == '/' ? p : path);
+}
+
+/*
+ * X509 certificate in a rank-balanced tree.
+ */
+struct cert {
+	RB_ENTRY(cert) entry;
+	unsigned long hash;
+	char *name;
+	X509 *x509;
+	char *path;
+};
+
+static void
+free_cert(struct cert *cert)
+{
+	free(cert->name);
+	X509_free(cert->x509);
+	free(cert->path);
+	free(cert);
+}
+
+static int
+certcmp(const struct cert *a, const struct cert *b)
+{
+	return (X509_cmp(a->x509, b->x509));
+}
+
+RB_HEAD(cert_tree, cert);
+static struct cert_tree trusted = RB_INITIALIZER(&trusted);
+static struct cert_tree untrusted = RB_INITIALIZER(&untrusted);
+RB_GENERATE_STATIC(cert_tree, cert, entry, certcmp);
+
+static void
+free_certs(struct cert_tree *tree)
+{
+	struct cert *cert, *tmp;
+
+	RB_FOREACH_SAFE(cert, cert_tree, tree, tmp) {
+		RB_REMOVE(cert_tree, tree, cert);
+		free_cert(cert);
+	}
+}
+
+static struct cert *
+find_cert(struct cert_tree *haystack, X509 *x509)
+{
+	struct cert needle = { .x509 = x509 };
+
+	return (RB_FIND(cert_tree, haystack, &needle));
+}
+
+/*
+ * File containing a certificate in a rank-balanced tree sorted by
+ * certificate hash and disambiguating counter.  This is needed because
+ * the certificate hash function is prone to collisions, necessitating a
+ * counter to distinguish certificates that hash to the same value.
+ */
+struct file {
+	RB_ENTRY(file) entry;
+	const struct cert *cert;
+	unsigned int c;
+};
+
+static int
+filecmp(const struct file *a, const struct file *b)
+{
+	if (a->cert->hash > b->cert->hash)
+		return (1);
+	if (a->cert->hash < b->cert->hash)
+		return (-1);
+	return (a->c - b->c);
+}
+
+RB_HEAD(file_tree, file);
+RB_GENERATE_STATIC(file_tree, file, entry, filecmp);
+
+/*
+ * Lexicographical sort for scandir().
+ */
+static int
+lexisort(const struct dirent **d1, const struct dirent **d2)
+{
+	return (strcmp((*d1)->d_name, (*d2)->d_name));
+}
+
+/*
+ * Read certificate(s) from a single file and insert them into a tree.
+ * Ignore certificates that already exist in the tree.  If exclude is not
+ * null, also ignore certificates that exist in exclude.
+ *
+ * Returns the number certificates added to the tree, or -1 on failure.
+ */
+static int
+read_cert(const char *path, struct cert_tree *tree, struct cert_tree *exclude)
+{
+	FILE *f;
+	X509 *x509;
+	X509_NAME *name;
+	struct cert *cert;
+	unsigned long hash;
+	int ni, no;
+
+	if ((f = fopen(path, "r")) == NULL) {
+		warn("%s", path);
+		return (-1);
+	}
+	for (ni = no = 0;
+	     (x509 = PEM_read_X509(f, NULL, NULL, NULL)) != NULL;
+	     ni++) {
+		hash = X509_subject_name_hash(x509);
+		if (exclude && find_cert(exclude, x509)) {
+			info("%08lx: excluded", hash);
+			X509_free(x509);
+			continue;
+		}
+		if (find_cert(tree, x509)) {
+			info("%08lx: duplicate", hash);
+			X509_free(x509);
+			continue;
+		}
+		if ((cert = calloc(1, sizeof(*cert))) == NULL)
+			err(1, NULL);
+		cert->x509 = x509;
+		name = X509_get_subject_name(x509);
+		cert->hash = X509_NAME_hash_ex(name, NULL, NULL, NULL);
+		cert->name = X509_NAME_oneline(name, NULL, 0);
+		cert->path = xstrdup(unexpand_path(path));
+		if (RB_INSERT(cert_tree, tree, cert) != NULL)
+			errx(1, "unexpected duplicate");
+		info("%08lx: %s", cert->hash, strrchr(cert->name, '=') + 1);
+		no++;
+	}
+	/*
+	 * ni is the number of certificates we found in the file.
+	 * no is the number of certificates that weren't already in our
+	 * tree or on the exclusion list.
+	 */
+	if (ni == 0)
+		warnx("%s: no valid certificates found", path);
+	fclose(f);
+	return (no);
+}
+
+/*
+ * Load all certificates found in the specified path into a tree,
+ * optionally excluding those that already exist in a different tree.
+ *
+ * Returns the number of certificates added to the tree, or -1 on failure.
+ */
+static int
+read_certs(const char *path, struct cert_tree *tree, struct cert_tree *exclude)
+{
+	struct stat sb;
+	char *paths[] = { (char *)(uintptr_t)path, NULL };
+	FTS *fts;
+	FTSENT *ent;
+	int fts_options = FTS_LOGICAL | FTS_NOCHDIR;
+	int ret, total = 0;
+
+	if (stat(path, &sb) != 0) {
+		return (-1);
+	} else if (!S_ISDIR(sb.st_mode)) {
+		errno = ENOTDIR;
+		return (-1);
+	}
+	if ((fts = fts_open(paths, fts_options, NULL)) == NULL)
+		err(1, "fts_open()");
+	while ((ent = fts_read(fts)) != NULL) {
+		if (ent->fts_info != FTS_F) {
+			if (ent->fts_info == FTS_ERR)
+				warnc(ent->fts_errno, "fts_read()");
+			continue;
+		}
+		info("found %s", ent->fts_path);
+		ret = read_cert(ent->fts_path, tree, exclude);
+		if (ret > 0)
+			total += ret;
+	}
+	fts_close(fts);
+	return (total);
+}
+
+/*
+ * Save the contents of a cert tree to disk.
+ *
+ * Returns 0 on success and -1 on failure.
+ */
+static int
+write_certs(const char *dir, struct cert_tree *tree)
+{
+	struct file_tree files = RB_INITIALIZER(&files);
+	struct cert *cert;
+	struct file *file, *tmp;
+	struct dirent **dents, **ent;
+	char *path, *tmppath = NULL;
+	FILE *f;
+	mode_t mode = 0444;
+	int cmp, d, fd, ndents, ret = 0;
+
+	/*
+	 * Start by generating unambiguous file names for each certificate
+	 * and storing them in lexicographical order
+	 */
+	RB_FOREACH(cert, cert_tree, tree) {
+		if ((file = calloc(1, sizeof(*file))) == NULL)
+			err(1, NULL);
+		file->cert = cert;
+		for (file->c = 0; file->c < INT_MAX; file->c++)
+			if (RB_INSERT(file_tree, &files, file) == NULL)
+				break;
+		if (file->c == INT_MAX)
+			errx(1, "unable to disambiguate %08lx", cert->hash);
+		free(cert->path);
+		cert->path = xasprintf("%08lx.%d", cert->hash, file->c);
+	}
+	/*
+	 * Open and scan the directory.
+	 */
+	if ((d = open(dir, O_DIRECTORY | O_RDONLY)) < 0 ||
+	    (ndents = fdscandir(d, &dents, NULL, lexisort)) < 0)
+		err(1, "%s", dir);
+	/*
+	 * Iterate over the directory listing and the certificate listing
+	 * in parallel.  If the directory listing gets ahead of the
+	 * certificate listing, we need to write the current certificate
+	 * and advance the certificate listing.  If the certificate
+	 * listing is ahead of the directory listing, we need to delete
+	 * the current file and advance the directory listing.  If they
+	 * are neck and neck, we have a match and could in theory compare
+	 * the two, but in practice it's faster to just replace the
+	 * current file with the current certificate (and advance both).
+	 */
+	ent = dents;
+	file = RB_MIN(file_tree, &files);
+	for (;;) {
+		if (ent < dents + ndents) {
+			/* skip directories */
+			if ((*ent)->d_type == DT_DIR) {
+				free(*ent++);
+				continue;
+			}
+			if (file != NULL) {
+				/* compare current dirent to current cert */
+				path = file->cert->path;
+				cmp = strcmp((*ent)->d_name, path);
+			} else {
+				/* trailing files in directory */
+				path = NULL;
+				cmp = -1;
+			}
+		} else {
+			if (file != NULL) {
+				/* trailing certificates */
+				path = file->cert->path;
+				cmp = 1;
+			} else {
+				/* end of both lists */
+				path = NULL;
+				break;
+			}
+		}
+		if (cmp < 0) {
+			/* a file on disk with no matching certificate */
+			info("removing %s/%s", dir, (*ent)->d_name);
+			if (!dryrun)
+				(void)unlinkat(d, (*ent)->d_name, 0);
+			free(*ent++);
+			continue;
+		}
+		if (cmp == 0) {
+			/* a file on disk with a matching certificate */
+			info("replacing %s/%s", dir, (*ent)->d_name);
+			if (dryrun) {
+				fd = open(_PATH_DEVNULL, O_WRONLY);
+			} else {
+				tmppath = xasprintf(".%s", path);
+				fd = openat(d, tmppath,
+				    O_CREAT | O_WRONLY | O_TRUNC, mode);
+				if (!unprivileged && fd >= 0)
+					(void)fchmod(fd, mode);
+			}
+			free(*ent++);
+		} else {
+			/* a certificate with no matching file */
+			info("writing %s/%s", dir, path);
+			if (dryrun) {
+				fd = open(_PATH_DEVNULL, O_WRONLY);
+			} else {
+				tmppath = xasprintf(".%s", path);
+				fd = openat(d, tmppath,
+				    O_CREAT | O_WRONLY | O_EXCL, mode);
+			}
+		}
+		/* write the certificate */
+		if (fd < 0 ||
+		    (f = fdopen(fd, "w")) == NULL ||
+		    !PEM_write_X509(f, file->cert->x509)) {
+			if (tmppath != NULL && fd >= 0) {
+				int serrno = errno;
+				(void)unlinkat(d, tmppath, 0);
+				errno = serrno;
+			}
+			err(1, "%s/%s", dir, tmppath ? tmppath : path);
+		}
+		/* rename temp file if applicable */
+		if (tmppath != NULL) {
+			if (ret == 0 && renameat(d, tmppath, d, path) != 0) {
+				warn("%s/%s", dir, path);
+				ret = -1;
+			}
+			if (ret != 0)
+				(void)unlinkat(d, tmppath, 0);
+			free(tmppath);
+			tmppath = NULL;
+		}
+		/* emit metalog */
+		if (mlf != NULL) {
+			fprintf(mlf, "%s/%s type=file "
+			    "uname=%s gname=%s mode=%#o size=%ld\n",
+			    unexpand_path(dir), path,
+			    uname, gname, mode, ftell(f));
+		}
+		fclose(f);
+		/* advance certificate listing */
+		tmp = RB_NEXT(file_tree, &files, file);
+		RB_REMOVE(file_tree, &files, file);
+		free(file);
+		file = tmp;
+	}
+	free(dents);
+	close(d);
+	return (ret);
+}
+
+/*
+ * Save all certs in a tree to a single file (bundle).
+ *
+ * Returns 0 on success and -1 on failure.
+ */
+static int
+write_bundle(const char *dir, const char *file, struct cert_tree *tree)
+{
+	struct cert *cert;
+	char *tmpfile = NULL;
+	FILE *f;
+	int d, fd, ret = 0;
+	mode_t mode = 0444;
+
+	if (dir != NULL) {
+		if ((d = open(dir, O_DIRECTORY | O_RDONLY)) < 0)
+			err(1, "%s", dir);
+	} else {
+		dir = ".";
+		d = AT_FDCWD;
+	}
+	info("writing %s/%s", dir, file);
+	if (dryrun) {
+		fd = open(_PATH_DEVNULL, O_WRONLY);
+	} else {
+		tmpfile = xasprintf(".%s", file);
+		fd = openat(d, tmpfile, O_WRONLY | O_CREAT | O_EXCL, mode);
+	}
+	if (fd < 0 || (f = fdopen(fd, "w")) == NULL) {
+		if (tmpfile != NULL && fd >= 0) {
+			int serrno = errno;
+			(void)unlinkat(d, tmpfile, 0);
+			errno = serrno;
+		}
+		err(1, "%s/%s", dir, tmpfile ? tmpfile : file);
+	}
+	RB_FOREACH(cert, cert_tree, tree) {
+		if (!PEM_write_X509(f, cert->x509)) {
+			warn("%s/%s", dir, tmpfile ? tmpfile : file);
+			ret = -1;
+			break;
+		}
+	}
+	if (tmpfile != NULL) {
+		if (ret == 0 && renameat(d, tmpfile, d, file) != 0) {
+			warn("%s/%s", dir, file);
+			ret = -1;
+		}
+		if (ret != 0)
+			(void)unlinkat(d, tmpfile, 0);
+		free(tmpfile);
+	}
+	if (ret == 0 && mlf != NULL) {
+		fprintf(mlf,
+		    "%s/%s type=file uname=%s gname=%s mode=%#o size=%ld\n",
+		    unexpand_path(dir), file, uname, gname, mode, ftell(f));
+	}
+	fclose(f);
+	if (d != AT_FDCWD)
+		close(d);
+	return (ret);
+}
+
+/*
+ * Load trusted certificates.
+ *
+ * Returns the number of certificates loaded.
+ */
+static unsigned int
+load_trusted(bool all, struct cert_tree *exclude)
+{
+	unsigned int i, n;
+	int ret;
+
+	/* load external trusted certs */
+	for (i = n = 0; all && trusted_paths[i] != NULL; i++) {
+		ret = read_certs(trusted_paths[i], &trusted, exclude);
+		if (ret > 0)
+			n += ret;
+	}
+
+	/* load installed trusted certs */
+	ret = read_certs(trusted_dest, &trusted, exclude);
+	if (ret > 0)
+		n += ret;
+
+	info("%d trusted certificates found", n);
+	return (n);
+}
+
+/*
+ * Load untrusted certificates.
+ *
+ * Returns the number of certificates loaded.
+ */
+static unsigned int
+load_untrusted(bool all)
+{
+	char *path;
+	unsigned int i, n;
+	int ret;
+
+	/* load external untrusted certs */
+	for (i = n = 0; all && untrusted_paths[i] != NULL; i++) {
+		ret = read_certs(untrusted_paths[i], &untrusted, NULL);
+		if (ret > 0)
+			n += ret;
+	}
+
+	/* load installed untrusted certs */
+	ret = read_certs(untrusted_dest, &untrusted, NULL);
+	if (ret > 0)
+		n += ret;
+
+	/* load legacy untrusted certs */
+	path = expand_path(LEGACY_PATH);
+	ret = read_certs(path, &untrusted, NULL);
+	if (ret > 0) {
+		warnx("certificates found in legacy directory %s",
+		    path);
+		n += ret;
+	} else if (ret == 0) {
+		warnx("legacy directory %s can safely be deleted",
+		    path);
+	}
+	free(path);
+
+	info("%d untrusted certificates found", n);
+	return (n);
+}
+
+/*
+ * Save trusted certificates.
+ *
+ * Returns 0 on success and -1 on failure.
+ */
+static int
+save_trusted(void)
+{
+	int ret;
+
+	/* save untrusted certs */
+	ret = write_certs(trusted_dest, &trusted);
+	return (ret);
+}
+
+/*
+ * Save untrusted certificates.
+ *
+ * Returns 0 on success and -1 on failure.
+ */
+static int
+save_untrusted(void)
+{
+	int ret;
+
+	ret = write_certs(untrusted_dest, &untrusted);
+	return (ret);
+}
+
+/*
+ * Save certificate bundle.
+ *
+ * Returns 0 on success and -1 on failure.
+ */
+static int
+save_bundle(void)
+{
+	char *dir, *file, *sep;
+	int ret;
+
+	if ((sep = strrchr(bundle_dest, '/')) == NULL) {
+		dir = NULL;
+		file = bundle_dest;
+	} else {
+		dir = xasprintf("%.*s", (int)(sep - bundle_dest), bundle_dest);
+		file = sep + 1;
+	}
+	ret = write_bundle(dir, file, &trusted);
+	free(dir);
+	return (ret);
+}
+
+/*
*** 1032 LINES SKIPPED ***