Repositories / jai.git
jai.git
Clone (read-only): git clone http://git.guha-anderson.com/git/jai.git
@@ -1,11 +1,9 @@ -# generated *.o *~ .*~ .cache -/.deps -/Makefile -/Makefile.in +.deps +.dirstamp /aclocal.m4 /autom4te.cache/ /config.h @@ -19,6 +17,9 @@ /jai-*.tar.gz /jai.1 /jai.conf +/jai.suid /missing /stamp-h1 +Makefile +Makefile.in compile_commands.json
@@ -1 +1,3 @@ This program was hand-written by David Mazieres. + +The tests were written by AI.
@@ -29,3 +29,13 @@ To override the installation prefix: ./configure --prefix=/usr make sudo make install + +Most of the tests require a setuid root version of jai to test. Since +people's build directories are often mounted nosuid, make `jai.suid` +in the top of your build directory a symbolic link to a place with a +setuid executable. You can run: + + sudo test/setup-setuid.sh + +to do this automatically in on /var/tmp, which is generally mounted +setuid capable.
@@ -1,3 +1,5 @@ +SUBDIRS = tests + bin_PROGRAMS = jai AM_CXXFLAGS = $(MOUNT_CFLAGS) $(LIBACL_CFLAGS) @@ -20,10 +22,10 @@ bashcomp_DATA = bash-completion/jai EXTRA_DIST = jai.1 jai.1.md jai.conf.in bash-completion/jai logo.svg -CLEANFILES = *~ +CLEANFILES = *~ jai.suid DISTCLEANFILES = jai.conf -distclean-local: +maintainer-clean-local: -rm -rf .deps MAINTAINERCLEANFILES = \
@@ -1,6 +1,6 @@ AC_INIT([jai], [0.1], [https://www.scs.stanford.edu/~dm/addr/],, https://github.com/stanford-scs/jai) -AM_INIT_AUTOMAKE([foreign -Wall -Werror]) +AM_INIT_AUTOMAKE([foreign -Wall -Werror serial-tests]) AC_CONFIG_HEADERS([config.h]) : ${CXXLANG=-std=gnu++23} @@ -39,5 +39,11 @@ AC_SUBST([UNTRUSTED_USER]) AC_DEFINE_UNQUOTED([UNTRUSTED_USER], ["$UNTRUSTED_USER"], [Username for the sandboxed untrusted user]) -AC_CONFIG_FILES([Makefile jai.conf]) +TEST_ABS_TOP_BUILDDIR=`pwd -P` +TEST_ABS_TOP_SRCDIR=`cd "$srcdir" && pwd -P` +AC_SUBST([TEST_ABS_TOP_BUILDDIR]) +AC_SUBST([TEST_ABS_TOP_SRCDIR]) + +AC_CONFIG_FILES([Makefile tests/Makefile jai.conf tests/common.sh tests/setup-setuid.sh]) +AC_CONFIG_COMMANDS([test-scripts], [chmod +x tests/common.sh tests/setup-setuid.sh]) AC_OUTPUT
@@ -0,0 +1,5 @@ +/common.sh +/jai_test_probe +/jai_test_pty_driver +/options_test +/setup-setuid.sh
@@ -0,0 +1,40 @@ +AUTOMAKE_OPTIONS = subdir-objects + +TESTS = \ + options_test \ + basic.sh \ + casual-overlay.sh \ + config-dir-hidden.sh \ + config-precedence.sh \ + home-cwd-guard.sh \ + jail-files.sh \ + job-control.sh \ + job-control-same-pgrp-stop.sh \ + job-control-sigtstp-fallback.sh \ + job-control-sigtstp-preserve.sh \ + modes.sh \ + mask.sh \ + process-state.sh \ + strict-home-grant.sh \ + sudo-env.sh \ + storage.sh \ + storage-from-conf.sh \ + teardown.sh + +check_PROGRAMS = jai_test_probe jai_test_pty_driver options_test + +jai_test_probe_SOURCES = jai-test-probe.cc +jai_test_pty_driver_SOURCES = jai-test-pty-driver.cc +options_test_SOURCES = options_test.cc ../options.cc ../options.h + +EXTRA_DIST = $(TESTS) common.sh.in setup-setuid.sh.in + +CLEANFILES = *~ + +maintainer-clean-local: + -rm -rf .deps + +MAINTAINERCLEANFILES = $(srcdir)/Makefile.in + +all-local: + chmod +x $(builddir)/setup-setuid.sh
@@ -0,0 +1,32 @@ +#!/bin/sh + +. ./common.sh + +setup_test basic +init_config + +assert_path_exists "$CONFIG_DIR/.defaults" +assert_path_exists "$CONFIG_DIR/default.conf" +assert_path_exists "$CONFIG_DIR/default.jail" + +capture run_jai --version +assert_status 0 +assert_contains "$CAPTURE_STDOUT" "jai 0.1" +assert_contains "$CAPTURE_STDOUT" "https://github.com/stanford-scs/jai" + +capture run_jai --print-defaults +assert_status 0 +cmp -s "$CONFIG_DIR/.defaults" "$CAPTURE_OUT_FILE" || + fail "--print-defaults output does not match .defaults" + +cat >"$CONFIG_DIR/alt.conf" <<'EOF' +conf .defaults +mode bare +jail alt-name +command /usr/bin/env +EOF + +capture run_jai -D -C alt +assert_status 0 +assert_contains "$CAPTURE_STDOUT" "JAI_MODE=bare" +assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=alt-name"
@@ -0,0 +1,40 @@ +#!/bin/sh + +. ./common.sh + +setup_test casual-overlay +init_config + +HOST_HOME_FILE=$REAL_HOME/jai-test-home-$$ +CWD_FILE=$WORKDIR/granted.txt +UPPER_FILE=$CONFIG_DIR/default.changes/$(basename "$HOST_HOME_FILE") + +register_cleanup_path "$HOST_HOME_FILE" + +printf 'host-home' >"$HOST_HOME_FILE" +printf 'host-cwd' >"$CWD_FILE" + +capture_in_dir "$WORKDIR" run_jai /bin/sh -c ' + printf "%s\n%s\n" "$JAI_MODE" "$JAI_JAIL" + printf overlay > "$1" + printf granted > "$2" + printf private > /tmp/private-file + cat /var/tmp/private-file +' sh "$HOST_HOME_FILE" "$CWD_FILE" + +assert_status 0 +assert_contains "$CAPTURE_STDOUT" "casual" +assert_contains "$CAPTURE_STDOUT" "default" +assert_contains "$CAPTURE_STDOUT" "private" +assert_file_equals "$HOST_HOME_FILE" "host-home" +assert_file_equals "$CWD_FILE" "granted" +assert_path_exists "$UPPER_FILE" +assert_file_equals "$UPPER_FILE" "overlay" + +mount_line=$(get_mount_line "/run/jai/$REAL_USER/default.home") +assert_contains "$mount_line" "upperdir=$CONFIG_DIR/default.changes" +assert_root_file_equals "/run/jai/$REAL_USER/tmp/default/private-file" "private" + +capture_in_dir "$WORKDIR" run_jai /bin/sh -c 'printf denied >/etc/jai-test-denied' +assert_status 1 +assert_contains "$CAPTURE_STDERR" "Read-only file system"
@@ -0,0 +1,376 @@ +#!/bin/sh + +set -eu + +ABS_TOP_BUILDDIR='@TEST_ABS_TOP_BUILDDIR@' +ABS_TOP_SRCDIR='@TEST_ABS_TOP_SRCDIR@' +JAI_BUILD_BIN=$ABS_TOP_BUILDDIR/jai +JAI_SUID_BIN=$ABS_TOP_BUILDDIR/jai.suid +JAI_BIN=$JAI_BUILD_BIN +JAI_TEST_PROBE=$ABS_TOP_BUILDDIR/tests/jai_test_probe +JAI_TEST_PTY_DRIVER=$ABS_TOP_BUILDDIR/tests/jai_test_pty_driver +UNTRUSTED_GECOS='JAI sandbox untrusted user' +UNTRUSTED_USER='@UNTRUSTED_USER@' +CURRENT_UID=$(id -u) +CURRENT_USER=$(id -un) +REAL_USER= +REAL_UID= +REAL_HOME= +TEST_PRIVILEGE_MODE= + +TEST_ROOT= +CONFIG_DIR= +WORKDIR= +CLEANUP_PATHS= +CAPTURE_STATUS= +CAPTURE_STDOUT= +CAPTURE_STDERR= +CAPTURE_OUT_FILE= +CAPTURE_ERR_FILE= + +skip() { + printf 'SKIP: %s\n' "$*" >&2 + exit 77 +} + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +ensure_current_build_jai() { + [ -x "$JAI_BUILD_BIN" ] || + fail "$JAI_BUILD_BIN has not been built; run make first" + + status=0 + make -C "$ABS_TOP_BUILDDIR" -q jai >/dev/null 2>&1 || status=$? + if [ "$status" -eq 0 ]; then + return + fi + [ "$status" -eq 1 ] || fail "make -C $ABS_TOP_BUILDDIR -q jai failed" + make -C "$ABS_TOP_BUILDDIR" jai >/dev/null +} + +resolve_real_identity() { + [ -n "$REAL_USER" ] && return + + if [ -n "${JAI_TEST_USER:-}" ]; then + REAL_USER=$JAI_TEST_USER + elif [ "$CURRENT_UID" -eq 0 ]; then + if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + REAL_USER=$SUDO_USER + else + owner_uid=$(stat -c %u "$ABS_TOP_BUILDDIR") || + fail "cannot stat $ABS_TOP_BUILDDIR" + if [ "$owner_uid" -eq 0 ]; then + owner_uid=$(stat -c %u "$ABS_TOP_SRCDIR") || + fail "cannot stat $ABS_TOP_SRCDIR" + fi + [ "$owner_uid" -ne 0 ] || + fail "running tests as root requires JAI_TEST_USER or a non-root build/src directory owner" + owner_entry=$(getent passwd "$owner_uid") || + fail "cannot find password entry for uid $owner_uid" + REAL_USER=$(printf '%s\n' "$owner_entry" | cut -d: -f1) + fi + else + REAL_USER=$CURRENT_USER + fi + + entry=$(getent passwd "$REAL_USER") || + fail "cannot find password entry for $REAL_USER" + REAL_UID=$(printf '%s\n' "$entry" | cut -d: -f3) + REAL_HOME=$(printf '%s\n' "$entry" | cut -d: -f6) +} + +find_setuid_jai() { + if [ -u "$JAI_SUID_BIN" ] && [ -r "$JAI_SUID_BIN" ] && [ -x "$JAI_SUID_BIN" ] && + cmp -s "$JAI_BUILD_BIN" "$JAI_SUID_BIN" 2>/dev/null; then + printf '%s\n' "$JAI_SUID_BIN" + return 0 + fi + return 1 +} + +configure_privilege_mode() { + [ -n "$TEST_PRIVILEGE_MODE" ] && return + + resolve_real_identity + requested_mode=${JAI_TEST_PRIVILEGE_MODE:-auto} + + case $requested_mode in + auto) + if [ "$CURRENT_UID" -eq 0 ]; then + TEST_PRIVILEGE_MODE=root + JAI_BIN=$JAI_BUILD_BIN + elif setuid_jai=$(find_setuid_jai); then + TEST_PRIVILEGE_MODE=setuid + JAI_BIN=$setuid_jai + else + skip "need a root shell or a current $JAI_SUID_BIN; run tests/setup-setuid.sh or relink jai.suid manually" + fi + ;; + root) + [ "$CURRENT_UID" -eq 0 ] || + skip "JAI_TEST_PRIVILEGE_MODE=root requires uid 0" + TEST_PRIVILEGE_MODE=root + JAI_BIN=$JAI_BUILD_BIN + ;; + setuid) + if setuid_jai=$(find_setuid_jai); then + TEST_PRIVILEGE_MODE=setuid + JAI_BIN=$setuid_jai + else + skip "current setuid jai not found at $JAI_SUID_BIN" + fi + ;; + *) + fail "unknown JAI_TEST_PRIVILEGE_MODE: $requested_mode" + ;; + esac +} + +register_cleanup_path() { + CLEANUP_PATHS="${CLEANUP_PATHS}${CLEANUP_PATHS:+ }$1" +} + +cleanup_jai() { + if [ -x "$JAI_BIN" ]; then + cfgdir=${CONFIG_DIR:-$REAL_HOME/.jai} + case $TEST_PRIVILEGE_MODE in + root) + env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$cfgdir" \ + "$JAI_BIN" -u >/dev/null 2>&1 || true + ;; + setuid) + env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$cfgdir" \ + "$JAI_BIN" -u >/dev/null 2>&1 || true + ;; + esac + fi +} + +cleanup_test() { + cleanup_jai || true + for path in $CLEANUP_PATHS; do + rm -rf -- "$path" >/dev/null 2>&1 || true + done + if [ -n "$TEST_ROOT" ] && [ -d "$TEST_ROOT" ]; then + rm -rf -- "$TEST_ROOT" >/dev/null 2>&1 || true + fi +} + +setup_test() { + label=$1 + configure_privilege_mode + ensure_current_build_jai + [ -x "$JAI_BIN" ] || fail "$JAI_BIN has not been built" + TEST_ROOT=$(mktemp -d "$ABS_TOP_BUILDDIR/test-tmp.$label.XXXXXX") + CONFIG_DIR=$(mktemp -d "/dev/shm/jai-config.$label.XXXXXX") + WORKDIR=$TEST_ROOT/work + CLEANUP_PATHS=$CONFIG_DIR + mkdir -p "$WORKDIR" + chmod 755 "$CONFIG_DIR" + if [ "$TEST_PRIVILEGE_MODE" = "root" ]; then + chown "$REAL_USER" "$TEST_ROOT" "$CONFIG_DIR" "$WORKDIR" + fi + CAPTURE_OUT_FILE=$TEST_ROOT/capture.out + CAPTURE_ERR_FILE=$TEST_ROOT/capture.err + trap cleanup_test EXIT HUP INT TERM + cleanup_jai +} + +require_built_helper() { + [ -x "$1" ] || fail "$1 has not been built" +} + +require_setsid() { + command -v setsid >/dev/null 2>&1 || skip "setsid not found" +} + +run_root() { + case $TEST_PRIVILEGE_MODE in + root) + "$@" + ;; + *) + fail "run_root is unavailable in $TEST_PRIVILEGE_MODE mode" + ;; + esac +} + +run_jai_launcher() { + case $TEST_PRIVILEGE_MODE in + setuid) + "$@" + ;; + root) + run_root "$@" + ;; + esac +} + +run_jai_no_tty() { + require_setsid + case $TEST_PRIVILEGE_MODE in + root) + setsid env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@" + ;; + setuid) + setsid env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@" + ;; + esac +} + +run_jai() { + case $TEST_PRIVILEGE_MODE in + root) + env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@" + ;; + setuid) + env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$JAI_BIN" "$@" + ;; + esac +} + +run_jai_with_env() { + envpair=$1 + shift + case $TEST_PRIVILEGE_MODE in + root) + env SUDO_USER="$REAL_USER" USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$envpair" \ + "$JAI_BIN" "$@" + ;; + setuid) + env -u SUDO_USER USER="$REAL_USER" LOGNAME="$REAL_USER" \ + HOME="$REAL_HOME" JAI_CONFIG_DIR="$CONFIG_DIR" "$envpair" \ + "$JAI_BIN" "$@" + ;; + esac +} + +init_config() { + run_jai --init >/dev/null 2>&1 + chmod 755 "$CONFIG_DIR" +} + +capture() { + if "$@" >"$CAPTURE_OUT_FILE" 2>"$CAPTURE_ERR_FILE"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi + CAPTURE_STDOUT=$(cat "$CAPTURE_OUT_FILE") + CAPTURE_STDERR=$(cat "$CAPTURE_ERR_FILE") +} + +capture_in_dir() { + dir=$1 + shift + if (cd "$dir" && "$@") >"$CAPTURE_OUT_FILE" 2>"$CAPTURE_ERR_FILE"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi + CAPTURE_STDOUT=$(cat "$CAPTURE_OUT_FILE") + CAPTURE_STDERR=$(cat "$CAPTURE_ERR_FILE") +} + +assert_status() { + [ "$CAPTURE_STATUS" -eq "$1" ] || + fail "expected status $1, got $CAPTURE_STATUS" +} + +assert_eq() { + [ "$1" = "$2" ] || fail "expected [$2], got [$1]" +} + +assert_contains() { + printf '%s\n' "$1" | grep -F -- "$2" >/dev/null || + fail "expected output to contain [$2]" +} + +assert_not_contains() { + if printf '%s\n' "$1" | grep -F -- "$2" >/dev/null; then + fail "expected output not to contain [$2]" + fi +} + +assert_path_exists() { + [ -e "$1" ] || fail "expected path to exist: $1" +} + +assert_path_missing() { + [ ! -e "$1" ] || fail "expected path to be absent: $1" +} + +assert_file_equals() { + actual=$(cat "$1") + [ "$actual" = "$2" ] || fail "expected $1 to contain [$2], got [$actual]" +} + +assert_root_file_equals() { + capture cat "$1" + assert_status 0 + assert_eq "$CAPTURE_STDOUT" "$2" +} + +assert_output_line() { + grep -Fx -- "$1" "$CAPTURE_OUT_FILE" >/dev/null || + fail "expected output line [$1]" +} + +assert_no_output_line() { + if grep -Fx -- "$1" "$CAPTURE_OUT_FILE" >/dev/null; then + fail "did not expect output line [$1]" + fi +} + +assert_root_path_exists() { + test -e "$1" || fail "expected root-visible path to exist: $1" +} + +assert_root_path_missing() { + if test -e "$1"; then + fail "expected root-visible path to be absent: $1" + fi +} + +get_mount_line() { + awk -v mp="$1" ' + $5 == mp { print; found = 1 } + END { exit found ? 0 : 1 } + ' /proc/self/mountinfo +} + +assert_mount_exists() { + get_mount_line "$1" >/dev/null 2>&1 || + fail "expected mount to exist at $1" +} + +assert_no_mount() { + if get_mount_line "$1" >/dev/null 2>&1; then + fail "expected no mount at $1" + fi +} + +ensure_untrusted_user() { + if ! getent passwd "$UNTRUSTED_USER" >/dev/null 2>&1; then + skip "$UNTRUSTED_USER is missing; run systemd-sysusers ./jai.conf" + fi + entry=$(getent passwd "$UNTRUSTED_USER") + uid=$(printf '%s\n' "$entry" | cut -d: -f3) + home=$(printf '%s\n' "$entry" | cut -d: -f6) + gecos=$(printf '%s\n' "$entry" | cut -d: -f5) + [ "$uid" -ne 0 ] || fail "$UNTRUSTED_USER must not have uid 0" + [ "$home" = "/" ] || fail "$UNTRUSTED_USER must have home /" + [ "$gecos" = "$UNTRUSTED_GECOS" ] || + fail "$UNTRUSTED_USER must have GECOS $UNTRUSTED_GECOS" + UNTRUSTED_UID=$uid +}
@@ -0,0 +1,19 @@ +#!/bin/sh + +. ./common.sh + +setup_test xfail-config-dir-hidden + +CONFIG_DIR=$REAL_HOME/jai-test-config-hidden-$$ +register_cleanup_path "$CONFIG_DIR" +mkdir -p "$CONFIG_DIR" + +init_config +ensure_untrusted_user + +capture_in_dir "$REAL_HOME" run_jai -m strict -D /usr/bin/true +if [ "$CAPTURE_STATUS" -ne 0 ]; then + printf '%s\n' 'FAIL: strict mode should not fail just because JAI_CONFIG_DIR is under $HOME and already hidden' >&2 + printf '%s\n' "$CAPTURE_STDERR" >&2 + exit 1 +fi
@@ -0,0 +1,45 @@ +#!/bin/sh + +. ./common.sh + +setup_test config-precedence +init_config + +cat >"$CONFIG_DIR/shared.conf" <<'EOF' +setenv FROM_SHARED=shared +unsetenv KEEP_ME +EOF + +cat >"$CONFIG_DIR/probe.conf" <<'EOF' +conf .defaults +conf shared.conf +mode bare +jail from-config +setenv FROM_PROBE=probe +command /usr/bin/env +EOF + +capture_in_dir "$WORKDIR" run_jai_with_env KEEP_ME=keep -D probe +assert_status 0 +assert_contains "$CAPTURE_STDOUT" "FROM_SHARED=shared" +assert_contains "$CAPTURE_STDOUT" "FROM_PROBE=probe" +assert_contains "$CAPTURE_STDOUT" "JAI_MODE=bare" +assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=from-config" +assert_no_output_line "KEEP_ME=keep" + +capture_in_dir "$WORKDIR" run_jai_with_env KEEP_ME=keep --setenv KEEP_ME --setenv CLI_OVERRIDE=cli -j cli-name -m casual probe +assert_status 0 +assert_output_line "KEEP_ME=keep" +assert_output_line "CLI_OVERRIDE=cli" +assert_contains "$CAPTURE_STDOUT" "JAI_MODE=casual" +assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=cli-name" + +cat >"$CONFIG_DIR/expand.conf" <<'EOF' +conf .defaults +setenv EXPANDED=${SRC_VALUE} +command /usr/bin/env +EOF + +capture_in_dir "$WORKDIR" run_jai_with_env SRC_VALUE=expanded -C expand +assert_status 0 +assert_output_line "EXPANDED=expanded"
@@ -0,0 +1,14 @@ +#!/bin/sh + +. ./common.sh + +setup_test home-cwd-guard +init_config + +capture_in_dir "$REAL_HOME" run_jai /usr/bin/true +assert_status 1 +assert_contains "$CAPTURE_STDERR" 'Refusing to grant your entire home directory to jailed code.' + +capture grep -F 'If you are in your home directory, you can launch jai with' \ + "$ABS_TOP_SRCDIR/jai.1.md" +assert_status 0
@@ -0,0 +1,190 @@ +#include <csignal> +#include <cstdlib> +#include <filesystem> +#include <iostream> +#include <set> +#include <string> +#include <string_view> +#include <system_error> +#include <vector> + +#include <dirent.h> +#include <fcntl.h> +#include <sys/ioctl.h> +#include <sys/stat.h> +#include <unistd.h> + +namespace { + +[[noreturn]] void +die(std::string_view msg) +{ + std::cerr << "FAIL: " << msg << '\n'; + std::exit(1); +} + +void +check(bool ok, std::string_view msg) +{ + if (!ok) + die(msg); +} + +int +signal_from_name(std::string_view name) +{ + if (name == "stop") + return SIGSTOP; + if (name == "tstp") + return SIGTSTP; + if (name == "ttin") + return SIGTTIN; + if (name == "ttou") + return SIGTTOU; + die("unknown signal name"); +} + +std::string +join_pids(const std::set<int> &pids) +{ + std::string out; + bool first = true; + for (int pid : pids) { + if (!first) + out += ','; + first = false; + out += std::to_string(pid); + } + return out; +} + +std::set<int> +numeric_proc_entries() +{ + std::set<int> out; + DIR *dir = opendir("/proc"); + check(dir != nullptr, "opendir(/proc) failed"); + while (dirent *de = readdir(dir)) { + char *end{}; + long v = std::strtol(de->d_name, &end, 10); + if (*de->d_name != '\0' && end && *end == '\0') + out.insert(static_cast<int>(v)); + } + closedir(dir); + return out; +} + +int +open_tty() +{ + return open("/dev/tty", O_RDWR | O_CLOEXEC); +} + +long +foreground_pgrp(int ttyfd) +{ + if (ttyfd < 0) + return -1; + auto pg = tcgetpgrp(ttyfd); + if (pg == -1) + return -1; + return pg; +} + +void +print_probe() +{ + struct stat tmp{}, vartmp{}; + check(stat("/tmp", &tmp) == 0, "stat(/tmp) failed"); + check(stat("/var/tmp", &vartmp) == 0, "stat(/var/tmp) failed"); + + int ttyfd = open_tty(); + const auto pids = numeric_proc_entries(); + + std::cout << "pid=" << getpid() << '\n'; + std::cout << "ppid=" << getppid() << '\n'; + std::cout << "pgid=" << getpgid(0) << '\n'; + std::cout << "sid=" << getsid(0) << '\n'; + std::cout << "tty=" << (ttyfd >= 0 ? "yes" : "no") << '\n'; + std::cout << "fg_pgrp=" << foreground_pgrp(ttyfd) << '\n'; + std::cout << "tmp_same_inode=" + << ((tmp.st_dev == vartmp.st_dev && tmp.st_ino == vartmp.st_ino) + ? "yes" + : "no") + << '\n'; + std::cout << "proc_pids=" << join_pids(pids) << '\n'; + + if (ttyfd >= 0) + close(ttyfd); +} + +void +run_suspend(int argc, char **argv) +{ + bool new_pgrp = false; + bool foreground = false; + int stop_signal = SIGSTOP; + + for (int i = 2; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "--new-pgrp") + new_pgrp = true; + else if (arg == "--foreground") + foreground = true; + else if (arg.starts_with("--signal=")) + stop_signal = signal_from_name(arg.substr(9)); + else + die("unknown suspend argument"); + } + + if (new_pgrp) + check(setpgid(0, 0) == 0, "setpgid failed"); + + int ttyfd = open_tty(); + if (foreground && ttyfd >= 0) { + struct sigaction old_act{}, ign{}; + ign.sa_handler = SIG_IGN; + check(sigaction(SIGTTOU, &ign, &old_act) == 0, "sigaction(SIGTTOU) failed"); + check(tcsetpgrp(ttyfd, getpgrp()) == 0, "tcsetpgrp failed"); + check(sigaction(SIGTTOU, &old_act, nullptr) == 0, + "restoring SIGTTOU failed"); + } + + std::cout << "READY" + << " pid=" << getpid() << " ppid=" << getppid() + << " pgid=" << getpgid(0) << " sid=" << getsid(0) + << " fg_pgrp=" << foreground_pgrp(ttyfd) + << " signal=" << stop_signal << '\n' + << std::flush; + + check(raise(stop_signal) == 0, "raise failed"); + + std::cout << "RESUMED" + << " pid=" << getpid() << " pgid=" << getpgid(0) + << " fg_pgrp=" << foreground_pgrp(ttyfd) << '\n' + << std::flush; + + if (ttyfd >= 0) + close(ttyfd); +} + +} // namespace + +int +main(int argc, char **argv) +{ + std::ios::sync_with_stdio(false); + + if (argc < 2) + die("missing subcommand"); + + std::string_view cmd = argv[1]; + if (cmd == "probe") + print_probe(); + else if (cmd == "suspend") + run_suspend(argc, argv); + else + die("unknown subcommand"); + + return 0; +}
@@ -0,0 +1,462 @@ +#include <chrono> +#include <csignal> +#include <cstdlib> +#include <cstring> +#include <iostream> +#include <optional> +#include <string> +#include <string_view> +#include <thread> +#include <vector> + +#include <fcntl.h> +#include <poll.h> +#include <pty.h> +#include <signal.h> +#include <sys/ioctl.h> +#include <sys/wait.h> +#include <unistd.h> + +namespace { + +using clock_type = std::chrono::steady_clock; + +struct Config { + enum class ParentMode { orphan, shell }; + enum class StopExpectation { exact, any }; + + std::string jai_bin; + std::string helper_bin; + std::string config_dir; + std::string workdir; + std::string user; + std::string signal_name = "stop"; + int signal_number = SIGSTOP; + bool new_pgrp = false; + bool foreground = false; + ParentMode parent_mode = ParentMode::orphan; + StopExpectation stop_expectation = StopExpectation::exact; +}; + +[[noreturn]] void +die(std::string_view msg) +{ + std::cerr << "FAIL: " << msg << '\n'; + std::exit(1); +} + +void +check(bool ok, std::string_view msg) +{ + if (!ok) + die(msg); +} + +int +parse_signal(std::string_view name) +{ + if (name == "stop") + return SIGSTOP; + if (name == "tstp") + return SIGTSTP; + if (name == "ttin") + return SIGTTIN; + if (name == "ttou") + return SIGTTOU; + die("unknown signal"); +} + +Config +parse_args(int argc, char **argv) +{ + Config cfg; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + auto need_value = [&](std::string_view name) -> std::string { + if (i + 1 >= argc) + die(std::string(name) + " requires a value"); + return argv[++i]; + }; + + if (arg == "--jai-bin") + cfg.jai_bin = need_value("--jai-bin"); + else if (arg == "--helper-bin") + cfg.helper_bin = need_value("--helper-bin"); + else if (arg == "--config-dir") + cfg.config_dir = need_value("--config-dir"); + else if (arg == "--workdir") + cfg.workdir = need_value("--workdir"); + else if (arg == "--user") + cfg.user = need_value("--user"); + else if (arg == "--new-pgrp") + cfg.new_pgrp = true; + else if (arg == "--foreground") + cfg.foreground = true; + else if (arg == "--shell-parent") + cfg.parent_mode = Config::ParentMode::shell; + else if (arg == "--accept-any-stop-signal") + cfg.stop_expectation = Config::StopExpectation::any; + else if (arg.starts_with("--signal=")) + cfg.signal_name = std::string(arg.substr(9)); + else + die(std::string("unknown argument: ") + std::string(arg)); + } + + cfg.signal_number = parse_signal(cfg.signal_name); + check(!cfg.jai_bin.empty(), "--jai-bin is required"); + check(!cfg.helper_bin.empty(), "--helper-bin is required"); + check(!cfg.config_dir.empty(), "--config-dir is required"); + check(!cfg.workdir.empty(), "--workdir is required"); + check(!cfg.user.empty(), "--user is required"); + return cfg; +} + +std::string +read_until(int fd, std::string_view needle, std::chrono::milliseconds timeout) +{ + auto deadline = clock_type::now() + timeout; + std::string out; + char buf[4096]; + + for (;;) { + if (out.find(needle) != std::string::npos) + return out; + + auto now = clock_type::now(); + if (now >= deadline) + break; + + auto wait_ms = + std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now) + .count(); + pollfd pfd{.fd = fd, .events = POLLIN | POLLHUP, .revents = 0}; + int pr = poll(&pfd, 1, static_cast<int>(wait_ms)); + if (pr < 0) { + if (errno == EINTR) + continue; + die(std::string("poll failed: ") + std::strerror(errno)); + } + if (pr == 0) + continue; + + ssize_t n = read(fd, buf, sizeof(buf)); + if (n > 0) { + out.append(buf, static_cast<std::size_t>(n)); + continue; + } + if (n == 0 || errno == EIO) + break; + if (errno == EINTR) + continue; + die(std::string("read failed: ") + std::strerror(errno)); + } + + die(std::string("timeout waiting for \"") + std::string(needle) + + "\"; output was:\n" + out); +} + +int +wait_for_stop(pid_t pid, std::chrono::milliseconds timeout) +{ + auto deadline = clock_type::now() + timeout; + for (;;) { + int status = 0; + pid_t r = waitpid(pid, &status, WUNTRACED | WCONTINUED | WNOHANG); + if (r == -1) { + if (errno == EINTR) + continue; + die(std::string("waitpid failed: ") + std::strerror(errno)); + } + if (r == pid) { + if (WIFSTOPPED(status)) + return WSTOPSIG(status); + if (WIFEXITED(status)) + die(std::string("jai exited early with status ") + + std::to_string(WEXITSTATUS(status))); + if (WIFSIGNALED(status)) + die(std::string("jai died from signal ") + + std::to_string(WTERMSIG(status))); + } + + if (clock_type::now() >= deadline) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + die("jai never stopped after the child self-suspended"); +} + +void +wait_for_exit(pid_t pid, std::chrono::milliseconds timeout) +{ + auto deadline = clock_type::now() + timeout; + for (;;) { + int status = 0; + pid_t r = waitpid(pid, &status, WUNTRACED | WCONTINUED | WNOHANG); + if (r == -1) { + if (errno == EINTR) + continue; + die(std::string("waitpid failed: ") + std::strerror(errno)); + } + if (r == pid) { + if (WIFEXITED(status)) { + if (WEXITSTATUS(status) != 0) + die(std::string("jai exited with status ") + + std::to_string(WEXITSTATUS(status))); + return; + } + if (WIFSIGNALED(status)) + die(std::string("jai died from signal ") + + std::to_string(WTERMSIG(status))); + } + + if (clock_type::now() >= deadline) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + die("timed out waiting for jai to exit"); +} + +void +kill_group(pid_t pgid) +{ + if (pgid > 0) + kill(-pgid, SIGKILL); +} + +void +tcsetpgrp_ignore_ttou(int tty_fd, pid_t pgid) +{ + struct sigaction old_act{}, ign{}; + ign.sa_handler = SIG_IGN; + check(sigaction(SIGTTOU, &ign, &old_act) == 0, "sigaction(SIGTTOU) failed"); + check(tcsetpgrp(tty_fd, pgid) == 0, "tcsetpgrp failed"); + check(sigaction(SIGTTOU, &old_act, nullptr) == 0, + "restoring SIGTTOU failed"); +} + +[[noreturn]] void +child_exec_orphan(const Config &cfg, int master_fd, int slave_fd) +{ + close(master_fd); + check(setsid() != -1, "setsid failed"); + check(ioctl(slave_fd, TIOCSCTTY, 0) != -1, "TIOCSCTTY failed"); + check(tcsetpgrp(slave_fd, getpid()) != -1, "tcsetpgrp failed"); + + check(dup2(slave_fd, STDIN_FILENO) != -1, "dup2(stdin) failed"); + check(dup2(slave_fd, STDOUT_FILENO) != -1, "dup2(stdout) failed"); + check(dup2(slave_fd, STDERR_FILENO) != -1, "dup2(stderr) failed"); + if (slave_fd > STDERR_FILENO) + close(slave_fd); + + check(chdir(cfg.workdir.c_str()) == 0, "chdir failed"); + + setenv("SUDO_USER", cfg.user.c_str(), 1); + setenv("USER", cfg.user.c_str(), 1); + setenv("LOGNAME", cfg.user.c_str(), 1); + setenv("JAI_CONFIG_DIR", cfg.config_dir.c_str(), 1); + setenv("TERM", "dumb", 1); + + std::vector<std::string> argv_store; + auto push = [&](std::string s) { argv_store.push_back(std::move(s)); }; + + push(cfg.jai_bin); + push(cfg.helper_bin); + push("suspend"); + if (cfg.new_pgrp) + push("--new-pgrp"); + if (cfg.foreground) + push("--foreground"); + push(std::string("--signal=") + cfg.signal_name); + + std::vector<char *> args; + args.reserve(argv_store.size() + 1); + for (auto &arg : argv_store) + args.push_back(arg.data()); + args.push_back(nullptr); + + execv(cfg.jai_bin.c_str(), args.data()); + std::cerr << "FAIL: execv(" << cfg.jai_bin << ") failed: " + << std::strerror(errno) << '\n'; + std::exit(1); +} + +[[noreturn]] void +child_exec_shell(const Config &cfg, int master_fd, int slave_fd) +{ + close(master_fd); + check(setpgid(0, 0) == 0, "setpgid failed"); + + check(dup2(slave_fd, STDIN_FILENO) != -1, "dup2(stdin) failed"); + check(dup2(slave_fd, STDOUT_FILENO) != -1, "dup2(stdout) failed"); + check(dup2(slave_fd, STDERR_FILENO) != -1, "dup2(stderr) failed"); + if (slave_fd > STDERR_FILENO) + close(slave_fd); + + check(chdir(cfg.workdir.c_str()) == 0, "chdir failed"); + + setenv("SUDO_USER", cfg.user.c_str(), 1); + setenv("USER", cfg.user.c_str(), 1); + setenv("LOGNAME", cfg.user.c_str(), 1); + setenv("JAI_CONFIG_DIR", cfg.config_dir.c_str(), 1); + setenv("TERM", "dumb", 1); + + std::vector<std::string> argv_store; + auto push = [&](std::string s) { argv_store.push_back(std::move(s)); }; + + push(cfg.jai_bin); + push(cfg.helper_bin); + push("suspend"); + if (cfg.new_pgrp) + push("--new-pgrp"); + if (cfg.foreground) + push("--foreground"); + push(std::string("--signal=") + cfg.signal_name); + + std::vector<char *> args; + args.reserve(argv_store.size() + 1); + for (auto &arg : argv_store) + args.push_back(arg.data()); + args.push_back(nullptr); + + execv(cfg.jai_bin.c_str(), args.data()); + std::cerr << "FAIL: execv(" << cfg.jai_bin << ") failed: " + << std::strerror(errno) << '\n'; + std::exit(1); +} + +[[noreturn]] void +shell_parent_exec(const Config &cfg, int master_fd, int slave_fd) +{ + check(setsid() != -1, "setsid failed"); + check(ioctl(slave_fd, TIOCSCTTY, 0) != -1, "TIOCSCTTY failed"); + struct sigaction ign{}; + ign.sa_handler = SIG_IGN; + check(sigaction(SIGHUP, &ign, nullptr) == 0, "sigaction(SIGHUP) failed"); + pid_t shell_pgid = getpgrp(); + + pid_t pid = fork(); + check(pid != -1, "fork failed"); + if (pid == 0) + child_exec_shell(cfg, master_fd, slave_fd); + + if (setpgid(pid, pid) == -1 && errno != EACCES) + die(std::string("setpgid(child) failed: ") + std::strerror(errno)); + tcsetpgrp_ignore_ttou(slave_fd, pid); + + struct Cleanup { + pid_t pid; + int master_fd; + int slave_fd; + pid_t shell_pgid; + ~Cleanup() + { + if (shell_pgid > 0) + tcsetpgrp_ignore_ttou(slave_fd, shell_pgid); + kill_group(pid); + int status = 0; + while (waitpid(pid, &status, WNOHANG) > 0) + ; + if (master_fd >= 0) + close(master_fd); + if (slave_fd >= 0) + close(slave_fd); + } + } cleanup{pid, master_fd, slave_fd, shell_pgid}; + + std::string output = + read_until(master_fd, "READY", std::chrono::milliseconds(4000)); + int stop_sig = wait_for_stop(pid, std::chrono::milliseconds(4000)); + if (cfg.stop_expectation == Config::StopExpectation::exact && + stop_sig != cfg.signal_number) + die(std::string("jai stopped with signal ") + std::to_string(stop_sig) + + ", expected " + std::to_string(cfg.signal_number)); + + tcsetpgrp_ignore_ttou(slave_fd, shell_pgid); + tcsetpgrp_ignore_ttou(slave_fd, pid); + check(kill(-pid, SIGCONT) == 0, "kill(SIGCONT) failed"); + output += read_until(master_fd, "RESUMED", std::chrono::milliseconds(4000)); + wait_for_exit(pid, std::chrono::milliseconds(4000)); + + if (output.find("READY") == std::string::npos || + output.find("RESUMED") == std::string::npos) + die("missing helper output"); + + tcsetpgrp_ignore_ttou(slave_fd, shell_pgid); + cleanup.pid = -1; + close(master_fd); + cleanup.master_fd = -1; + close(slave_fd); + cleanup.slave_fd = -1; + std::exit(0); +} + +} // namespace + +int +main(int argc, char **argv) +{ + Config cfg = parse_args(argc, argv); + + int master_fd = -1; + int slave_fd = -1; + check(openpty(&master_fd, &slave_fd, nullptr, nullptr, nullptr) == 0, + "openpty failed"); + + if (cfg.parent_mode == Config::ParentMode::shell) { + pid_t shell_pid = fork(); + check(shell_pid != -1, "fork failed"); + if (shell_pid == 0) + shell_parent_exec(cfg, master_fd, slave_fd); + close(master_fd); + close(slave_fd); + int status = 0; + check(waitpid(shell_pid, &status, 0) == shell_pid, "waitpid failed"); + if (WIFEXITED(status)) + return WEXITSTATUS(status); + if (WIFSIGNALED(status)) + die(std::string("shell parent died from signal ") + + std::to_string(WTERMSIG(status))); + die("shell parent ended unexpectedly"); + } + + pid_t pid = fork(); + check(pid != -1, "fork failed"); + if (pid == 0) + child_exec_orphan(cfg, master_fd, slave_fd); + close(slave_fd); + + struct Cleanup { + pid_t pid; + int master_fd; + ~Cleanup() + { + kill_group(pid); + int status = 0; + while (waitpid(pid, &status, WNOHANG) > 0) + ; + if (master_fd >= 0) + close(master_fd); + } + } cleanup{pid, master_fd}; + + std::string output = + read_until(master_fd, "READY", std::chrono::milliseconds(4000)); + int stop_sig = wait_for_stop(pid, std::chrono::milliseconds(4000)); + if (cfg.stop_expectation == Config::StopExpectation::exact && + stop_sig != cfg.signal_number) + die(std::string("jai stopped with signal ") + std::to_string(stop_sig) + + ", expected " + std::to_string(cfg.signal_number)); + + check(kill(-pid, SIGCONT) == 0, "kill(SIGCONT) failed"); + output += read_until(master_fd, "RESUMED", std::chrono::milliseconds(4000)); + wait_for_exit(pid, std::chrono::milliseconds(4000)); + + if (output.find("READY") == std::string::npos || + output.find("RESUMED") == std::string::npos) + die("missing helper output"); + + cleanup.pid = -1; + close(master_fd); + cleanup.master_fd = -1; + return 0; +}
@@ -0,0 +1,58 @@ +#!/bin/sh + +. ./common.sh + +setup_test jail-files +init_config +ensure_untrusted_user + +cat >"$CONFIG_DIR/jail-include.conf" <<'EOF' +setenv FROM_JAIL_INCLUDE=include +EOF + +cat >"$CONFIG_DIR/probe.conf" <<'EOF' +conf .defaults +mode strict +jail from-conf +command /usr/bin/env +EOF + +cat >"$CONFIG_DIR/from-conf.jail" <<'EOF' +conf jail-include.conf +mode bare +setenv FROM_DOTJAIL=yes +EOF + +capture_in_dir "$WORKDIR" run_jai -D -C probe +assert_status 0 +assert_output_line "FROM_DOTJAIL=yes" +assert_output_line "FROM_JAIL_INCLUDE=include" +assert_contains "$CAPTURE_STDOUT" "JAI_MODE=bare" +assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=from-conf" + +capture_in_dir "$WORKDIR" run_jai -C probe -m strict +assert_status 0 +assert_contains "$CAPTURE_STDOUT" "JAI_MODE=strict" +assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=from-conf" + +capture_in_dir "$WORKDIR" run_jai --jail created /usr/bin/env +assert_status 0 +assert_contains "$CAPTURE_STDOUT" "JAI_MODE=strict" +assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=created" +assert_path_exists "$CONFIG_DIR/created.jail" +grep -Fx 'mode strict' "$CONFIG_DIR/created.jail" >/dev/null || + fail "created.jail should record strict mode by default" + +cat >"$CONFIG_DIR/bad.conf" <<'EOF' +conf .defaults +jail bad +command /usr/bin/true +EOF + +cat >"$CONFIG_DIR/bad.jail" <<'EOF' +jail nope +EOF + +capture_in_dir "$WORKDIR" run_jai -C bad +assert_status 1 +assert_contains "$CAPTURE_STDERR" "cannot set name from a .jail file or include"
@@ -0,0 +1,20 @@ +#!/bin/sh + +. ./common.sh + +setup_test job-control-same-pgrp-stop +require_built_helper "$JAI_TEST_PROBE" +require_built_helper "$JAI_TEST_PTY_DRIVER" +init_config + +capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \ + --jai-bin "$JAI_BIN" \ + --helper-bin "$JAI_TEST_PROBE" \ + --config-dir "$CONFIG_DIR" \ + --workdir "$WORKDIR" \ + --user "$REAL_USER" \ + --signal=stop +if [ "$CAPTURE_STATUS" -ne 0 ]; then + printf '%s\n' "$CAPTURE_STDERR" >&2 + fail 'same-process-group SIGSTOP job-control path failed' +fi
@@ -0,0 +1,23 @@ +#!/bin/sh + +. ./common.sh + +setup_test job-control-sigtstp-fallback +require_built_helper "$JAI_TEST_PROBE" +require_built_helper "$JAI_TEST_PTY_DRIVER" +init_config + +capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \ + --jai-bin "$JAI_BIN" \ + --helper-bin "$JAI_TEST_PROBE" \ + --config-dir "$CONFIG_DIR" \ + --workdir "$WORKDIR" \ + --user "$REAL_USER" \ + --accept-any-stop-signal \ + --new-pgrp \ + --foreground \ + --signal=tstp +if [ "$CAPTURE_STATUS" -ne 0 ]; then + printf '%s\n' "$CAPTURE_STDERR" >&2 + fail 'orphaned job-control topology should still stop and resume jai' +fi
@@ -0,0 +1,23 @@ +#!/bin/sh + +. ./common.sh + +setup_test job-control-sigtstp-preserve +require_built_helper "$JAI_TEST_PROBE" +require_built_helper "$JAI_TEST_PTY_DRIVER" +init_config + +capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \ + --jai-bin "$JAI_BIN" \ + --helper-bin "$JAI_TEST_PROBE" \ + --config-dir "$CONFIG_DIR" \ + --workdir "$WORKDIR" \ + --user "$REAL_USER" \ + --shell-parent \ + --new-pgrp \ + --foreground \ + --signal=tstp +if [ "$CAPTURE_STATUS" -ne 0 ]; then + printf '%s\n' "$CAPTURE_STDERR" >&2 + fail 'shell job-control topology should preserve SIGTSTP exactly' +fi
@@ -0,0 +1,22 @@ +#!/bin/sh + +. ./common.sh + +setup_test job-control +require_built_helper "$JAI_TEST_PROBE" +require_built_helper "$JAI_TEST_PTY_DRIVER" +init_config + +capture run_jai_launcher "$JAI_TEST_PTY_DRIVER" \ + --jai-bin "$JAI_BIN" \ + --helper-bin "$JAI_TEST_PROBE" \ + --config-dir "$CONFIG_DIR" \ + --workdir "$WORKDIR" \ + --user "$REAL_USER" \ + --new-pgrp \ + --foreground \ + --signal=stop +if [ "$CAPTURE_STATUS" -ne 0 ]; then + printf '%s\n' "$CAPTURE_STDERR" >&2 + fail 'own-process-group SIGSTOP job-control path failed' +fi
@@ -0,0 +1,39 @@ +#!/bin/sh + +. ./common.sh + +setup_test mask +init_config + +TARGET_NAME=jai-mask-target-$$ +TARGET_PATH=$REAL_HOME/$TARGET_NAME + +register_cleanup_path "$TARGET_PATH" + +printf 'mask-me' >"$TARGET_PATH" + +cat >"$CONFIG_DIR/mask-on.conf" <<EOF +conf .defaults +mode casual +jail masked +mask $TARGET_NAME +EOF + +cleanup_jai +capture_in_dir "$WORKDIR" run_jai -C mask-on /bin/sh -c '[ -e "$1" ] && printf visible || printf hidden' sh "$TARGET_PATH" +assert_status 0 +assert_eq "$CAPTURE_STDOUT" "hidden" + +cat >"$CONFIG_DIR/mask-off.conf" <<EOF +conf .defaults +mode casual +jail unmasked +mask $TARGET_NAME +unmask $TARGET_NAME +EOF + +cleanup_jai +capture_in_dir "$WORKDIR" run_jai -C mask-off /bin/sh -c '[ -e "$1" ] && printf visible || printf hidden' sh "$TARGET_PATH" +assert_status 0 +assert_eq "$CAPTURE_STDOUT" "visible" +assert_file_equals "$TARGET_PATH" "mask-me"
@@ -0,0 +1,35 @@ +#!/bin/sh + +. ./common.sh + +setup_test modes +init_config +ensure_untrusted_user + +HOST_HOME_FILE=$REAL_HOME/jai-strict-hidden-$$ +STRICT_GRANTED=$WORKDIR/strict-granted.txt + +register_cleanup_path "$HOST_HOME_FILE" + +printf 'secret' >"$HOST_HOME_FILE" + +capture_in_dir "$WORKDIR" run_jai -j named /usr/bin/env +assert_status 0 +assert_contains "$CAPTURE_STDOUT" "JAI_MODE=strict" +assert_contains "$CAPTURE_STDOUT" "JAI_JAIL=named" + +capture_in_dir "$WORKDIR" run_jai -m strict /bin/sh -c 'id -u' +assert_status 0 +assert_eq "$CAPTURE_STDOUT" "$UNTRUSTED_UID" + +capture run_jai -D -m bare -j barecase /bin/sh -c 'id -u' +assert_status 0 +assert_eq "$CAPTURE_STDOUT" "$REAL_UID" + +capture_in_dir "$WORKDIR" run_jai -m strict /bin/sh -c '[ -e "$1" ] && printf visible || printf hidden' sh "$HOST_HOME_FILE" +assert_status 0 +assert_eq "$CAPTURE_STDOUT" "hidden" + +capture_in_dir "$WORKDIR" run_jai -m strict /bin/sh -c 'printf strict > "$1"' sh "$STRICT_GRANTED" +assert_status 0 +assert_file_equals "$STRICT_GRANTED" "strict"
@@ -0,0 +1,204 @@ +#include "../options.h" + +#include <cassert> +#include <filesystem> +#include <functional> +#include <initializer_list> +#include <string> +#include <string_view> +#include <vector> + +std::filesystem::path prog; + +namespace { + +struct Argv { + std::vector<std::string> args; + std::vector<char *> ptrs; + + Argv(std::initializer_list<std::string_view> init) + { + args.reserve(init.size()); + ptrs.reserve(init.size()); + for (auto arg : init) + args.emplace_back(arg); + for (auto &arg : args) + ptrs.push_back(arg.data()); + } +}; + +void +expect_error(std::string_view msg, const std::function<void()> &f) +{ + try { + f(); + assert(false); + } catch (const Options::Error &e) { + assert(std::string_view(e.what()) == msg); + } +} + +void +test_parse_argv() +{ + bool a = false; + bool b = false; + std::string dir; + std::string opt = "unset"; + + Options o; + o("-a", [&] { a = true; }); + o("-b", [&] { b = true; }); + o("-d", "--dir", [&](std::string arg) { dir = arg; }); + o("-o", "--opt", [&](std::string arg = "default") { opt = arg; }); + + Argv argv{"prog", "-ab", "-dpath", "-o", "file"}; + auto rest = o.parse_argv(argv.ptrs.size(), argv.ptrs.data()); + + assert(a); + assert(b); + assert(dir == "path"); + assert(opt == "default"); + assert(rest.size() == 1); + assert(std::string_view(rest.front()) == "file"); +} + +void +test_parse_stops() +{ + bool seen = false; + + Options o; + o("-a", [&] { seen = true; }); + + { + Argv argv{"prog", "-a", "file", "--ignored"}; + auto rest = o.parse_argv(argv.ptrs.size(), argv.ptrs.data()); + assert(seen); + assert(rest.size() == 2); + assert(std::string_view(rest[0]) == "file"); + } + + { + Argv argv{"prog", "--", "-a"}; + auto rest = o.parse_argv(argv.ptrs.size(), argv.ptrs.data()); + assert(rest.size() == 1); + assert(std::string_view(rest[0]) == "-a"); + } +} + +void +test_parse_errors() +{ + Options o; + o("-a", [] {}); + o("--all", [] {}); + o("-d", "--dir", [](std::string) {}); + + expect_error("unknown option --bad", [&] { + Argv argv{"prog", "--bad"}; + o.parse_argv(argv.ptrs.size(), argv.ptrs.data()); + }); + expect_error("option -d requires an argument", [&] { + Argv argv{"prog", "-d"}; + o.parse_argv(argv.ptrs.size(), argv.ptrs.data()); + }); + expect_error("option --all takes no argument", [&] { + Argv argv{"prog", "--all=x"}; + o.parse_argspan(std::span{argv.args}.subspan(1)); + }); + + bool seen = false; + Options p; + p("-a", [&] { seen = true; }); + expect_error("unknown option -b", [&] { + Argv argv{"prog", "-ab"}; + p.parse_argv(argv.ptrs.size(), argv.ptrs.data()); + }); + assert(seen); +} + +void +test_complete_args() +{ + using C = Options::Completions; + + Options o; + o("-a", [] {}); + o("-b", "--beta", [] {}); + o("-d", "--dir", [](std::string) {}); + o("-o", "--opt", [](std::string arg = {}) { (void)arg; }); + + { + Argv argv{"prog", "-"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kRawCompletions); + assert((c.vals == std::vector<std::string>{ + "--beta ", "--dir ", "--opt=", "-a ", "-b ", "-d ", "-o"})); + } + { + Argv argv{"prog", "--"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kRawCompletions); + assert((c.vals == + std::vector<std::string>{"--beta ", "--dir ", "--opt="})); + } + { + Argv argv{"prog", "-a"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kRawCompletions); + assert((c.vals == std::vector<std::string>{"-a "})); + } + { + Argv argv{"prog", "-d"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kRawCompletions); + assert((c.vals == std::vector<std::string>{"-d "})); + } + { + Argv argv{"prog", "-o"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kArgCompletions); + assert((c.vals == std::vector<std::string>{"-o", "", "-o"})); + } + { + Argv argv{"prog", "--dir", "fo"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kArgCompletions); + assert((c.vals == std::vector<std::string>{"--dir", "fo", ""})); + } + { + Argv argv{"prog", "--dir=fo"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kArgCompletions); + assert((c.vals == std::vector<std::string>{"--dir", "fo", "--dir="})); + } + { + Argv argv{"prog", "-adfo"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kArgCompletions); + assert((c.vals == std::vector<std::string>{"-d", "fo", "-ad"})); + } + { + Argv argv{"prog", "--opt", "file"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == 2); + assert(c.vals.empty()); + } + { + Argv argv{"prog", "--bad", "-"}; + auto c = o.complete_args(1, argv.ptrs.size(), argv.ptrs.data()); + assert(c.kind == C::kNoCompletions); + } +} + +} // namespace + +int +main() +{ + test_parse_argv(); + test_parse_stops(); + test_parse_errors(); + test_complete_args(); +}
@@ -0,0 +1,16 @@ +#!/bin/sh + +. ./common.sh + +setup_test process-state +require_built_helper "$JAI_TEST_PROBE" +init_config + +capture_in_dir "$WORKDIR" run_jai_no_tty "$JAI_TEST_PROBE" probe +assert_status 0 +assert_output_line "pid=2" +assert_output_line "ppid=1" +assert_output_line "tty=no" +assert_output_line "fg_pgrp=-1" +assert_output_line "tmp_same_inode=yes" +assert_output_line "proc_pids=1,2"
@@ -0,0 +1,134 @@ +#!/bin/sh + +set -eu + +abs_top_builddir='@TEST_ABS_TOP_BUILDDIR@' +link_path=$abs_top_builddir/jai.suid + +usage() { + cat <<EOF +usage: $0 [--user USER] + +Installs a setuid-root copy of jai for running the test suite without sudo. +The binary is placed in a private per-user directory under /var/tmp and +$link_path is updated to point to it. + +After setup, just run: + + make check +EOF +} + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +default_test_user() { + owner_uid=$(stat -c %u "$abs_top_builddir") || return 1 + if [ "$owner_uid" -eq 0 ]; then + owner_uid=$(stat -c %u "$(dirname "$abs_top_builddir")") || return 1 + fi + [ "$owner_uid" -ne 0 ] || return 1 + owner_entry=$(getent passwd "$owner_uid") || return 1 + printf '%s\n' "$owner_entry" | cut -d: -f1 +} + +test_user= + +while [ $# -gt 0 ]; do + case $1 in + --user) + [ $# -ge 2 ] || { + usage >&2 + exit 1 + } + test_user=$2 + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + usage >&2 + exit 1 + ;; + esac +done + +[ "$(id -u)" -eq 0 ] || fail 'setup-setuid.sh must be run as root' + +if [ -z "$test_user" ]; then + if [ -n "${JAI_TEST_USER:-}" ]; then + test_user=$JAI_TEST_USER + elif [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then + test_user=$SUDO_USER + elif test_user=$(default_test_user); then + : + else + fail 'specify --user when running as root without SUDO_USER or a non-root build directory owner' + fi +fi + +jai_bin=$abs_top_builddir/jai +install_root=/var/tmp/jai-test-$test_user +install_path=$install_root/jai + +[ -x "$jai_bin" ] || fail "$jai_bin has not been built" + +test_user_uid=$(id -u "$test_user") || fail "cannot find uid for $test_user" +test_user_gid=$(id -g "$test_user") || fail "cannot find primary gid for $test_user" + +prepare_install_root() { + if [ -e "$install_root" ]; then + [ -d "$install_root" ] || fail "$install_root exists but is not a directory" + [ ! -L "$install_root" ] || fail "$install_root must not be a symlink" + else + mkdir "$install_root" + fi + + chown "$test_user_uid:$test_user_gid" "$install_root" + chmod 700 "$install_root" + + [ -d "$install_root" ] || fail "$install_root is not a directory" + [ ! -L "$install_root" ] || fail "$install_root must not be a symlink" + + owner_uid=$(stat -Lc %u "$install_root") || fail "cannot stat $install_root" + mode=$(stat -Lc %a "$install_root") || fail "cannot stat $install_root" + [ "$owner_uid" = "$test_user_uid" ] || + fail "$install_root is not owned by $test_user" + [ "$mode" = "700" ] || + fail "$install_root must have mode 0700" +} + +installed_jai_is_current() { + [ -u "$install_path" ] || return 1 + [ -r "$install_path" ] || return 1 + [ -x "$install_path" ] || return 1 + mode=$(stat -Lc %a "$install_path") || return 1 + [ "$mode" = "4555" ] || return 1 + [ -L "$link_path" ] || return 1 + [ "$(readlink "$link_path")" = "$install_path" ] || return 1 + cmp -s "$jai_bin" "$install_path" 2>/dev/null +} + +update_link() { + rm -f "$link_path" + ln -s "$install_path" "$link_path" +} + +prepare_install_root + +if installed_jai_is_current; then + printf '%s is up to date\n' "$install_path" + exit 0 +fi + +install -o root -g root -m 4555 "$jai_bin" "$install_path" +update_link + +printf 'Installed %s\n' "$install_path" +printf 'Linked %s -> %s\n' "$link_path" "$install_path" +printf 'Users can remove %s when they are done\n' "$install_path" +printf 'Run: make check\n'
@@ -0,0 +1,27 @@ +#!/bin/sh + +. ./common.sh + +setup_test xfail-storage-from-conf +init_config + +STORAGE=$TEST_ROOT/storage +mkdir -p "$STORAGE" + +cat >"$CONFIG_DIR/probe.conf" <<EOF +conf .defaults +storage $STORAGE +jail storage-from-conf +command /usr/bin/env +EOF + +capture_in_dir "$WORKDIR" run_jai -C probe +if [ "$CAPTURE_STATUS" -ne 0 ]; then + printf '%s\n' 'FAIL: storage set in a .conf file should relocate .jail files and sandbox state' >&2 + printf '%s\n' "$CAPTURE_STDERR" >&2 + exit 1 +fi + +assert_output_line "JAI_JAIL=storage-from-conf" +assert_path_exists "$STORAGE/storage-from-conf.jail" +assert_path_missing "$CONFIG_DIR/storage-from-conf.jail"
@@ -0,0 +1,35 @@ +#!/bin/sh + +. ./common.sh + +setup_test storage +init_config + +STORAGE=$REAL_HOME/jai-storage-$$ +HOST_HOME_FILE=$REAL_HOME/jai-storage-home-$$ +UPPER_FILE=$STORAGE/default.changes/$(basename "$HOST_HOME_FILE") + +register_cleanup_path "$STORAGE" +register_cleanup_path "$HOST_HOME_FILE" + +mkdir -p "$STORAGE" +printf 'sentinel' >"$STORAGE/.sentinel" +printf 'host' >"$HOST_HOME_FILE" + +capture_in_dir "$WORKDIR" run_jai --storage "$STORAGE" /bin/sh -c ' + printf overlay > "$1" + if [ -e "$2/.sentinel" ]; then + printf exposed + else + printf hidden + fi +' sh "$HOST_HOME_FILE" "$STORAGE" + +assert_status 0 +assert_eq "$CAPTURE_STDOUT" "hidden" +assert_file_equals "$HOST_HOME_FILE" "host" +assert_path_exists "$UPPER_FILE" +assert_file_equals "$UPPER_FILE" "overlay" + +mount_line=$(get_mount_line "/run/jai/$REAL_USER/default.home") +assert_contains "$mount_line" "upperdir=$STORAGE/default.changes"
@@ -0,0 +1,31 @@ +#!/bin/sh + +. ./common.sh + +setup_test strict-home-grant +init_config +ensure_untrusted_user + +READ_FILE=$REAL_HOME/jai-strict-home-read-$$ +WRITE_FILE=$REAL_HOME/jai-strict-home-write-$$ + +register_cleanup_path "$READ_FILE" +register_cleanup_path "$WRITE_FILE" + +printf 'visible-from-home' >"$READ_FILE" +rm -f "$WRITE_FILE" + +capture_in_dir "$REAL_HOME" run_jai -m strict -D /bin/sh -c \ + '[ -e "$1" ] && printf visible || printf hidden' sh "$READ_FILE" +assert_status 0 +assert_eq "$CAPTURE_STDOUT" "hidden" + +capture_in_dir "$REAL_HOME" run_jai -m strict -D -d "$REAL_HOME" /bin/sh -c ' + pwd + cat "$1" + printf rewritten > "$2" +' sh "$READ_FILE" "$WRITE_FILE" +assert_status 0 +assert_output_line "$REAL_HOME" +assert_output_line "visible-from-home" +assert_file_equals "$WRITE_FILE" "rewritten"
@@ -0,0 +1,14 @@ +#!/bin/sh + +. ./common.sh + +setup_test sudo-env +init_config + +capture_in_dir "$WORKDIR" run_jai /usr/bin/env +assert_status 0 +assert_output_line "HOME=$REAL_HOME" +assert_output_line "USER=$REAL_USER" +assert_output_line "LOGNAME=$REAL_USER" +assert_no_output_line "HOME=/root" +assert_no_output_line "MAIL=/var/mail/root"
@@ -0,0 +1,30 @@ +#!/bin/sh + +. ./common.sh + +setup_test teardown +init_config + +HOST_HOME_FILE=$REAL_HOME/jai-teardown-home-$$ +UPPER_FILE=$CONFIG_DIR/default.changes/$(basename "$HOST_HOME_FILE") + +register_cleanup_path "$HOST_HOME_FILE" + +printf 'host' >"$HOST_HOME_FILE" + +capture_in_dir "$WORKDIR" run_jai /bin/sh -c ' + printf overlay > "$1" + printf keep > /tmp/keep +' sh "$HOST_HOME_FILE" +assert_status 0 + +assert_mount_exists "/run/jai/$REAL_USER/default.home" +assert_root_path_exists "/run/jai/$REAL_USER/tmp/default/keep" +assert_path_exists "$UPPER_FILE" + +capture run_jai -u +assert_status 0 +assert_no_mount "/run/jai/$REAL_USER/default.home" +assert_root_path_missing "/run/jai/$REAL_USER/default.home" +assert_root_path_missing "/run/jai/$REAL_USER/tmp/default" +assert_path_exists "$UPPER_FILE"