Radish alpha
H
rad:z3QDZAW2FAfuLvihrhiyDC9fAD8G9
HardenedBSD Package Manager
Radicle
Git
HardenedBSD-pkg libpkg triggers.c
/*-
 * Copyright (c) 2020-2026 Baptiste Daroussin <bapt@FreeBSD.org>
 *
 * SPDX-License-Identifier: BSD-2-Clause
 */

#include "pkg_config.h"

#if __has_include(<sys/capsicum.h>)
#include <sys/capsicum.h>
#define HAVE_CAPSICUM 1
#endif

#include <sys/stat.h>
#include <sys/wait.h>

#include <dirent.h>
#include <err.h>
#include <errno.h>
#include <fcntl.h>
#include <paths.h>
#include <spawn.h>
#include <xstring.h>

#include <pkg.h>
#include <private/pkg.h>
#include <private/event.h>
#include <private/lua.h>
#include <private/utils.h>

extern char **environ;

static const unsigned char litchar[] =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

static script_type_t
get_script_type(const char *str)
{
	if (STRIEQ(str, "lua"))
		return (SCRIPT_LUA);
	return (SCRIPT_UNKNOWN);
}

static ucl_object_t *
trigger_open_schema(void)
{
	ucl_object_t *trigger_schema;
	static const char trigger_schema_str[] = ""
		"{"
		"  type = object;"
		"  properties {"
		"    description: { type = string };"
		"    path: { "
		"      anyOf = [{"
		"        type = array; "
		"        item = { type = string };"
		"      }, {"
		"        type = string;"
		"      }]"
		"    };"
		"    path_glob: { "
		"      anyOf = [{"
		"        type = array; "
		"        item = { type = string };"
		"      }, {"
		"        type = string;"
		"      }]"
		"    };"
		"    path_regexp: { "
		"      anyOf = [{"
		"        type = array; "
		"        item = { type = string };"
		"      }, {"
		"        type = string;"
		"      }]"
		"    };"
		"    cleanup = { "
		"      type = object; "
		"      properties = {"
		"        type = { "
		"          type = string,"
		"          sandbox = boolean, "
		"          enum: [lua];"
		"        };"
		"        script = { type = string };"
		"      }; "
		"      required = [ type, script ];"
		"    };"
		"    trigger = { "
		"      type = object; "
		"      properties = {"
		"        type = { "
		"          type = string,"
		"          sandbox = boolean, "
		"          enum: [lua];"
		"        };"
		"        script = { type = string };"
		"      }; "
		"      required = [ type, script ];"
		"    };"
		"  }\n"
		"  required = [ trigger ];"
		"}";

	trigger_schema = ucl_parse_buf(trigger_schema_str,
	    sizeof(trigger_schema_str) - 1, "trigger schema");
	return (trigger_schema);
}

static bool
parse_trigger_script_block(const ucl_object_t *block, const char *block_name,
    const char *trigger_name, char **out_script, int *out_type, bool *out_sandbox)
{
	const ucl_object_t *o;

	o = ucl_object_find_key(block, "type");
	if (o == NULL) {
		pkg_emit_error("%s %s doesn't have a script type", block_name, trigger_name);
		return (false);
	}
	*out_type = get_script_type(ucl_object_tostring(o));
	if (*out_type == SCRIPT_UNKNOWN) {
		pkg_emit_error("Unknown script type for %s in %s", block_name, trigger_name);
		return (false);
	}
	o = ucl_object_find_key(block, "script");
	if (o == NULL) {
		pkg_emit_error("No script in %s %s", block_name, trigger_name);
		return (false);
	}
	*out_script = xstrdup(ucl_object_tostring(o));
	o = ucl_object_find_key(block, "sandbox");
	*out_sandbox = (o == NULL) ? true : ucl_object_toboolean(o);
	return (true);
}

static struct trigger *
trigger_load(int dfd, const char *name, bool cleanup_only, ucl_object_t *schema)
{
	ucl_object_t *obj = NULL;
	const ucl_object_t *block = NULL;
	int fd;
	struct ucl_schema_error err;
	struct trigger *t;

	fd = openat(dfd, name, O_RDONLY);
	if (fd == -1) {
		pkg_emit_error("Unable to open the trigger: %s", name);
		return (NULL);
	}

	obj = ucl_parse_fd(fd, name);
	close(fd);
	if (obj == NULL)
		return (NULL);

	if (!ucl_object_validate(schema, obj, &err)) {
		pkg_emit_error("trigger definition %s cannot be validated: %s", name, err.msg);
		ucl_object_unref(obj);
		return (NULL);
	}

	t = xcalloc(1, sizeof(*t));
	t->name = xstrdup(name);

	if (cleanup_only) {
		block = ucl_object_find_key(obj, "cleanup");
		if (block == NULL)
			goto err;
		if (!parse_trigger_script_block(block, "cleanup", name,
		    &t->cleanup.script, &t->cleanup.type, &t->cleanup.sandbox))
			goto err;
		ucl_object_unref(obj);
		return (t);
	}

	block = ucl_object_find_key(obj, "trigger");
	if (!parse_trigger_script_block(block, "trigger", name,
	    &t->script.script, &t->script.type, &t->script.sandbox))
		goto err;

	/* Load path patterns (required for any non-cleanup block) */
	block = ucl_object_find_key(obj, "path");
	if (block != NULL)
		t->path = ucl_object_ref(block);
	block = ucl_object_find_key(obj, "path_glob");
	if (block != NULL)
		t->path_glob = ucl_object_ref(block);
	block = ucl_object_find_key(obj, "path_regexp");
	if (block != NULL)
		t->path_regexp = ucl_object_ref(block);
	if (t->path == NULL &&
	    t->path_glob == NULL &&
	    t->path_regexp == NULL) {
		pkg_emit_error("No path* in trigger %s, skipping", name);
		goto err;
	}

	ucl_object_unref(obj);
	return (t);

err:
	if (t) {
		if (t->path != NULL)
			ucl_object_unref(t->path);
		if (t->path_glob != NULL)
			ucl_object_unref(t->path_glob);
		if (t->path_regexp != NULL)
			ucl_object_unref(t->path_regexp);
		free(t->script.script);
		free(t->cleanup.script);
		free(t);
	}
	ucl_object_unref(obj);
	return (NULL);
}

void
trigger_is_it_a_cleanup(struct triggers *t, const char *path)
{
	const char *trigger_name, *dir;
	const pkg_object *dirs, *cur;
	struct trigger *trig;
	pkg_iter it;

	if (t->schema == NULL)
		t->schema = trigger_open_schema();

	/* Check if the file was installed in a trigger directory. */
	it = NULL;
	trigger_name = NULL;
	dirs = pkg_config_get("PKG_TRIGGERS_DIR");
	while ((cur = pkg_object_iterate(dirs, &it))) {
		size_t len;

		dir = pkg_object_string(cur);
		len = strlen(dir);

		if (strncmp(path, dir, len) == 0) {
			trigger_name = path + strlen(dir);
			break;
		}
	}

	if (trigger_name == NULL)
		return;

	if (t->dfd == -1)
		t->dfd = openat(ctx.rootfd, RELATIVE_PATH(dir), O_DIRECTORY);

	trig = trigger_load(t->dfd, RELATIVE_PATH(trigger_name), true, t->schema);
	if (trig != NULL) {
		if (t->cleanup == NULL)
			t->cleanup = xcalloc(1, sizeof(*t->cleanup));

		vec_push(t->cleanup, trig);
	}
}

/*
 * Load triggers from a specific directory and add them to a vec.
 */
static void
triggers_load_from(trigger_t *triggers, bool cleanup_only, const char *dir)
{
	int dfd;
	DIR *d;
	struct dirent *e;
	struct trigger *t;
	ucl_object_t *schema;
	struct stat st;

	dfd = openat(ctx.rootfd, dir, O_DIRECTORY);
	if (dfd == -1) {
		if (errno != ENOENT)
			pkg_emit_error("Unable to open the trigger directory %s: %s",
			    dir, strerror(errno));
		return;
	}

	d = fdopendir(dfd);
	if (d == NULL) {
		pkg_emit_error("Unable to open the trigger directory %s: %s",
		    dir, strerror(errno));
		close(dfd);
		return;
	}

	schema = trigger_open_schema();

	while ((e = readdir(d)) != NULL) {
		const char *ext;
		/* ignore all hidden files */
		if (e->d_name[0] ==  '.')
			continue;
		/* only consider files ending with .ucl */
		ext = strrchr(e->d_name, '.');
		if (ext == NULL)
			continue;
		if (!STREQ(ext, ".ucl"))
			continue;
		/* only regular files are considered */
		if (fstatat(dfd, e->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
			pkg_emit_errno("fstatat", e->d_name);
			continue;
		}
		if (!S_ISREG(st.st_mode))
			continue;
		t = trigger_load(dfd, e->d_name, cleanup_only, schema);
		if (t != NULL)
			vec_push(triggers, t);
	}

	closedir(d);
	ucl_object_unref(schema);
}

/*
 * Load triggers from PKG_TRIGGERS_DIR.
 */
trigger_t *
triggers_load(bool cleanup_only)
{
	trigger_t *ret;
	const pkg_object *dirs, *cur;
	pkg_iter it = NULL;

	ret = xcalloc(1, sizeof(*ret));

	dirs = pkg_config_get("PKG_TRIGGERS_DIR");
	while ((cur = pkg_object_iterate(dirs, &it))) {
		const char *dir;

		dir = RELATIVE_PATH(pkg_object_string(cur));
		triggers_load_from(ret, cleanup_only, dir);
	}

	return (ret);
}

void
trigger_free(struct trigger *t)
{
	if (!t)
		return;
	free(t->name);
	if (t->path)
		ucl_object_unref(t->path);
	if (t->path_glob)
		ucl_object_unref(t->path_glob);
	if (t->path_regexp)
		ucl_object_unref(t->path_regexp);
	free(t->cleanup.script);
	free(t->script.script);
	free(t);
}

static char *
get_random_name(char name[])
{
	char *pos;
	int r;

	pos = name;
	while (*pos == 'X') {
#ifndef HAVE_ARC4RANDOM
		r = rand() % (sizeof(litchar) -1);
#else
		r = arc4random_uniform(sizeof(litchar) -1);
#endif
		*pos++ = litchar[r];
	}

	return (name);
}

static void
save_trigger(const char *script, bool sandbox, pkghash *args)
{
	int db = ctx.pkg_dbdirfd;
	pkghash_it it;

	if (!mkdirat_p(db, "triggers"))
		return;

	int trigfd = openat(db, "triggers", O_DIRECTORY);
	if (trigfd == -1) {
		pkg_errno("Failed to open '%s' as a directory", "triggers");
		return;
	}

#ifndef HAVE_ARC4RANDOM
	srand(time(NULL));
#endif

	int fd;
	for (;;) {
		char name[] = "XXXXXXXXXX";
		fd = openat(trigfd, get_random_name(name),
		    O_CREAT|O_RDWR|O_EXCL, 0644);
		if (fd != -1)
			break;
		if (errno == EEXIST)
			continue;
		pkg_errno("Can't create deferred triggers %s", name);
		return;
	}
	close(trigfd);
	FILE *f = fdopen(fd, "w");
	if (f == NULL) {
		close(fd);
		pkg_errno("Can't open deferred trigger for %s", "writing");
		return;
	}
	if (sandbox)
		fputs("--sandbox\n", f);
	fputs("--begin args\n", f);
	it = pkghash_iterator(args);
	while (pkghash_next(&it)) {
		fprintf(f, "-- %s\n", (char *)it.value);
	}
	fputs("--end args\n--\n", f);
	fprintf(f, "%s\n", script);
	fclose(f);
}

static int
trigger_execute_lua_common(const char *script, bool sandbox, pkghash *args,
    bool defer, const char *pkgname, const char *pkgversion, bool upgrade)
{
	lua_State *L;
	int pstat;
	pkghash_it it;

	if (defer && !sandbox && ctx.defer_triggers) {
		save_trigger(script, sandbox, args);
		return (EPKG_OK);
	}

	pid_t pid = fork();
	if (pid == 0) {
		L = luaL_newstate();
		luaL_openlibs(L);
		lua_override_ios(L, sandbox);
		static const luaL_Reg pkg_lib[] = {
			{ "print_msg", lua_print_msg },
			{ "filecmp", lua_pkg_filecmp },
			{ "copy", lua_pkg_copy },
			{ "stat", lua_stat },
			{ "readdir", lua_readdir },
			{ "exec", lua_exec },
			{ "exec_capture", lua_exec_capture },
			{ "symlink", lua_pkg_symlink },
			{ NULL, NULL },
		};
		luaL_newlib(L, pkg_lib);
		lua_setglobal(L, "pkg");
		lua_pushinteger(L, ctx.rootfd);
		lua_setglobal(L, "rootfd");
		if (pkgname != NULL) {
			lua_pushstring(L, pkgname);
			lua_setglobal(L, "pkg_name");
		}
		if (pkgversion != NULL) {
			lua_pushstring(L, pkgversion);
			lua_setglobal(L, "pkg_version");
		}
		lua_pushboolean(L, upgrade);
		lua_setglobal(L, "pkg_upgrade");
		char **arguments = NULL;
		int i = 0;
		if (args != NULL) {
			arguments = xcalloc(pkghash_count(args), sizeof(char*));
			it = pkghash_iterator(args);
			while (pkghash_next(&it)) {
				arguments[i++] = it.key;
			}
		}
		lua_args_table(L, arguments, i);
#ifdef HAVE_CAPSICUM
		if (sandbox) {
#ifndef COVERAGE
			if (cap_enter() < 0 && errno != ENOSYS) {
				err(1, "cap_enter failed");
			}
#endif
		}
#endif
		if (luaL_dostring(L, script)) {
			pkg_emit_error("Failed to execute lua trigger: "
					"%s", lua_tostring(L, -1));
			_exit(1);
		}
		if (lua_tonumber(L, -1) != 0) {
			lua_close(L);
			_exit(1);
		}
		lua_close(L);
		_exit(0);
	} else if (pid < 0) {
		pkg_emit_errno("Cannot fork", "lua_script");
		return (EPKG_FATAL);
	}
	while (waitpid(pid, &pstat, 0) == -1) {
		if (errno != EINTR) {
			pkg_emit_error("waitpid() failed: %s", strerror(errno));
			return (EPKG_FATAL );
		}
	}
	if (WEXITSTATUS(pstat) != 0) {
		pkg_emit_error("lua script failed");
		return (EPKG_FATAL);
	}

	return (EPKG_OK);
}

static int
trigger_execute_lua(const char *script, bool sandbox, pkghash *args)
{
	return (trigger_execute_lua_common(script, sandbox, args,
	    true, NULL, NULL, false));
}

static void
trigger_check_match(struct trigger *t, char *dir)
{
	const ucl_object_t *cur;
	ucl_object_iter_t it;

	if (t->path != NULL) {
		it = NULL;
		while ((cur = ucl_iterate_object(t->path, &it, true))) {
			if (STREQ(dir, ucl_object_tostring(cur))) {
				pkghash_safe_add(t->matched, dir, dir, NULL);
				return;
			}
		}
	}

	if (match_ucl_lists(dir, t->path_glob, t->path_regexp)) {
		pkghash_safe_add(t->matched, dir, dir, NULL);
	}
}

/*
 * first execute cleanup scripts from the triggers that are not there anymore,
 * then execute all per-transaction triggers that matched touched directories.
 * Always reload triggers from disk: new trigger files may have been installed
 * during the transaction.
 */
int
triggers_execute(struct triggers *t)
{
	trigger_t *triggers;
	int ret = EPKG_OK;

	triggers = triggers_load(false);

	pkg_emit_triggers_begin();
	if (t != NULL && t->cleanup != NULL) {
		vec_foreach(*t->cleanup, i) {
			pkg_emit_trigger(t->cleanup->d[i]->name, true);
			if (t->cleanup->d[i]->cleanup.type == SCRIPT_LUA) {
				ret = trigger_execute_lua(t->cleanup->d[i]->cleanup.script,
				    t->cleanup->d[i]->cleanup.sandbox, NULL);
			}
			if (ret != EPKG_OK)
				goto cleanup;
		}
	}

	if (ctx.touched_dir_hash) {
		pkghash_it it = pkghash_iterator(ctx.touched_dir_hash);
		while (pkghash_next(&it)) {
			vec_foreach(*triggers, i)
				trigger_check_match(triggers->d[i], it.key);
		}
	}

	vec_foreach(*triggers, i) {
		if (triggers->d[i]->matched == NULL)
			continue;
		pkg_emit_trigger(triggers->d[i]->name, false);
		if (triggers->d[i]->script.type == SCRIPT_LUA) {
			ret = trigger_execute_lua(triggers->d[i]->script.script,
			    triggers->d[i]->script.sandbox, triggers->d[i]->matched);
		}
		if (ret != EPKG_OK)
			goto cleanup;
	}
	pkg_emit_triggers_finished();

cleanup:
	vec_free_and_free(triggers, trigger_free);
	free(triggers);

	return (EPKG_OK);
}

/*
 * Match a dir against a trigger's path patterns, adding to local_matched.
 */
static bool
trigger_check_match_local(struct trigger *t, const char *dir, pkghash **matched)
{
	const ucl_object_t *cur;
	ucl_object_iter_t it;

	if (t->path != NULL) {
		it = NULL;
		while ((cur = ucl_iterate_object(t->path, &it, true))) {
			if (STREQ(dir, ucl_object_tostring(cur))) {
				pkghash_safe_add(*matched, dir, NULL, NULL);
				return (true);
			}
		}
	}

	if (match_ucl_lists(dir, t->path_glob, t->path_regexp)) {
		pkghash_safe_add(*matched, dir, NULL, NULL);
		return (true);
	}
	return (false);
}

/*
 * Per-package trigger subdirectory names, indexed by trigger_phase_t.
 */
static const char *trigger_phase_dirs[] = {
	[TRIGGER_PHASE_PRE_INSTALL] = "pre_install",
	[TRIGGER_PHASE_POST_INSTALL] = "post_install",
	[TRIGGER_PHASE_PRE_DEINSTALL] = "pre_deinstall",
	[TRIGGER_PHASE_POST_DEINSTALL] = "post_deinstall",
};

/*
 * Load per-package triggers from the phase-specific subdirectory
 * under each configured PKG_TRIGGERS_DIR.
 */
static trigger_t *
triggers_load_perpackage(trigger_phase_t phase)
{
	trigger_t *ret;
	const pkg_object *dirs, *cur;
	pkg_iter it = NULL;
	char path[MAXPATHLEN];

	ret = xcalloc(1, sizeof(*ret));

	dirs = pkg_config_get("PKG_TRIGGERS_DIR");
	while ((cur = pkg_object_iterate(dirs, &it))) {
		const char *dir;

		dir = RELATIVE_PATH(pkg_object_string(cur));
		snprintf(path, sizeof(path), "%s/%s",
		    dir, trigger_phase_dirs[phase]);
		triggers_load_from(ret, false, path);
	}

	return (ret);
}

/*
 * Execute per-package triggers for a given phase on a specific package.
 * Triggers are loaded from the phase-specific subdirectory (e.g.
 * post_install/) so only relevant triggers are parsed.  They are
 * reloaded from disk each time so that triggers installed or removed
 * by earlier packages in the same transaction are picked up.
 */
int
triggers_execute_perpackage(struct pkg *pkg,
    trigger_phase_t phase, bool upgrade)
{
	struct pkg_file *f = NULL;
	struct pkg_dir *d = NULL;
	pkghash *pkg_paths_hash = NULL;
	int ret = EPKG_OK;
	trigger_t *triggers;

	triggers = triggers_load_perpackage(phase);

	if (triggers->len == 0) {
		vec_free_and_free(triggers, trigger_free);
		free(triggers);
		return (EPKG_OK);
	}

	/* Build set of file paths and their parent directories */
	while (pkg_files(pkg, &f) == EPKG_OK) {
		char *dir, *slash;
		pkghash_safe_add(pkg_paths_hash, f->path, NULL, NULL);
		dir = xstrdup(f->path);
		slash = strrchr(dir, '/');
		if (slash != NULL) {
			*slash = '\0';
			pkghash_safe_add(pkg_paths_hash, dir, NULL, NULL);
		}
		free(dir);
	}
	while (pkg_dirs(pkg, &d) == EPKG_OK) {
		pkghash_safe_add(pkg_paths_hash, d->path, NULL, NULL);
	}

	if (pkg_paths_hash == NULL) {
		vec_free_and_free(triggers, trigger_free);
		free(triggers);
		return (EPKG_OK);
	}

	/* Match and execute */
	vec_foreach(*triggers, i) {
		struct trigger *trig = triggers->d[i];
		pkghash *local_matched = NULL;
		pkghash_it it = pkghash_iterator(pkg_paths_hash);

		while (pkghash_next(&it))
			trigger_check_match_local(trig, it.key, &local_matched);

		if (local_matched == NULL)
			continue;

		pkg_emit_trigger(trig->name, false);
		if (trig->script.type == SCRIPT_LUA) {
			ret = trigger_execute_lua_common(trig->script.script,
			    trig->script.sandbox, local_matched, false,
			    pkg->name, pkg->version, upgrade);
		}
		pkghash_destroy(local_matched);
		if (ret != EPKG_OK) {
			pkg_emit_error("per-package trigger %s failed "
			    "for %s, continuing", trig->name, pkg->name);
			ret = EPKG_OK;
		}
	}

	pkghash_destroy(pkg_paths_hash);
	vec_free_and_free(triggers, trigger_free);
	free(triggers);
	return (ret);
}

void
append_touched_dir(const char *path)
{
	pkghash_safe_add(ctx.touched_dir_hash, path, NULL, NULL);
}

void
append_touched_file(const char *path)
{
	char *newpath, *walk;

	newpath = xstrdup(path);
	walk = strrchr(newpath, '/');
	if (walk == NULL)
		return;
	*walk = '\0';

	pkghash_safe_add(ctx.touched_dir_hash, newpath, NULL, NULL );
	free(newpath);
}

static void
exec_deferred(int dfd, const char *name)
{
	bool sandbox = false;
	pkghash *args = NULL;
	xstring *script = NULL;

	int fd = openat(dfd, name, O_RDONLY);
	if (fd == -1) {
		pkg_errno("Unable to open the trigger '%s'", name);
		return;
	}
	FILE *f = fdopen(fd, "r");
	if (f == NULL) {
		close(fd);
		pkg_errno("Unable to open the trigger '%s'", name);
		return;
	}

	char *line = NULL;
	size_t linecap = 0;
	ssize_t linelen;
	char *walk;
	bool inargs = false;
	while ((linelen = getline(&line, &linecap, f)) > 0) {
		walk = line;
		walk += 2; /* '--' aka lua comments */
		if (strncmp(walk, "sandbox", 7) == 0) {
			sandbox = true;
			continue;
		}
		if (strncmp(walk, "begin args", 10) == 0) {
			inargs = true;
			continue;
		}
		if (strncmp(walk, "end args", 8) == 0) {
			inargs = false;
			script = xstring_new();
			continue;
		}
		if (inargs) {
			walk++; /* skip the space */
			if (line[linelen -1] == '\n')
				line[linelen -1] = '\0';
			pkghash_safe_add(args, walk, NULL, NULL);
		}
		if (script != NULL)
			fputs(line, script->fp);
	}
	free(line);
	fclose(f);
	if (script == NULL) {
		pkghash_destroy(args);
		return;
	}
	char *s = xstring_get(script);
	if (trigger_execute_lua(s, sandbox, args) == EPKG_OK) {
		unlinkat(dfd, name, 0);
	}
	free(s);
	pkghash_destroy(args);
}

int
pkg_execute_deferred_triggers(void)
{
	struct dirent *e;
	struct stat st;
	int dbdir = pkg_get_dbdirfd();

	int trigfd = openat(dbdir, "triggers", O_DIRECTORY);
	if (trigfd == -1)
		return (EPKG_OK);

	DIR *d = fdopendir(trigfd);
	if (d == NULL) {
		close(trigfd);
		pkg_emit_error("Unable to open the deferred trigger directory");
		return (EPKG_FATAL);
	}

	while ((e = readdir(d)) != NULL) {
		/* ignore all hiddn files */
		if (e->d_name[0] == '.')
			continue;
		/* only regular files are considered */
		if (fstatat(trigfd, e->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
			pkg_emit_errno("fstatat", e->d_name);
			return (EPKG_FATAL);
		}
		exec_deferred(trigfd, e->d_name);
	}
	closedir(d);
	return (EPKG_OK);
}