Commit d6a889d667 for strongswan.org

commit d6a889d6671f840cbddaacd924c89e89d44877cc
Author: Tobias Brunner <tobias@strongswan.org>
Date:   Thu Oct 1 10:25:56 2026 +0200

    process: Use posix_spawn() if available and we can close FDs > 2

    This adds support to use `posix_spawn()` if we can close FDs > 2 to
    implement the `close_all` flag.  Since POSIX does not define a way to
    do that, there are two different proprietary approaches in use today.
    With glibc, there is `posix_spawn_file_actions_addclosefrom_np`, which
    works like the `closefrom()` we use after `fork()`.  On macOS, there is
    an attribute instead (`POSIX_SPAWN_CLOEXEC_DEFAULT`), which causes all
    FDs that are not used in other actions like they had the `FD_CLOEXEC`
    flag applied (this includes FDs 0-2, which we explicitly inherit if they
    are not redirected).  If we don't find either of these, we fall back to
    the existing `fork()/execve()` combination.

    The main reason for doing this is an issue in our CI on macOS that
    started with the most recent image update.  In about 1 in 4-5 runs, one
    particular test case failed (`test_echo` in the "process" suite).  As it
    turned out, it wasn't that particular case, but simply what it does,
    namely calling `fork()`.  In particular, it was always the fourth
    `fork()` call by our test runner that triggered the problem.  Test cases
    could be moved around, looped, delayed, it was always the fourth.  What
    happened is that the child process just died and some debugging showed
    our "child" code never even ran (i.e. the process died before returning
    from `fork()`).  And the reason for that was eventually found in libSystem
    registering an atfork handler that calls `_notify_fork_child` in
    libsystem_notify, which calls `calloc()`.  Because we compile with ASan
    in our CI this call went to ASan's wrapper and that protects its state
    with an `_os_unfair_lock`.  The crash was then caused because the system
    viewed the lock as corrupted and caused an `EXC_BREAKPOINT` exception
    that the kernel turned into a `SIGKILL` that terminated the child.
    What's left unresolved is why exactly it was always the fourth `fork()`
    call that triggered this, all before or after worked fine, and often
    it wasn't even triggered.  Anyway, this change should avoid this whole
    class of issues as no atfork handlers run for `posix_spawn()`.

    Also properly close the pipe ends in the fallback in case `close_all` is
    not used.  And added test cases that test the flag.  Also added an
    additional condition for `test_not_found` as implementations of
    `posix_spawn()` can fail by letting the child exit with 127 if exec
    fails.

    To make the `/dev/fd/<fd>` check work on FreeBSD in the unlikely case
    the FD is > 9 (so far only seen to happen on Linux), we mount `fdescfs`
    in our CI.

diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 8c8ab7acbb..e8c26ad90e 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -41,6 +41,9 @@ jobs:
           prepare: |
             ./scripts/test.sh deps
           run: |
+            # make sure we have /dev/fd for fds > 2 for our process_t test suite
+            mount -t fdescfs null /dev/fd
+            #
             ./scripts/test.sh
       - if: ${{ failure() }}
         uses: actions/upload-artifact@v6
diff --git a/configure.ac b/configure.ac
index 89327946f3..834c9027d9 100644
--- a/configure.ac
+++ b/configure.ac
@@ -679,6 +679,9 @@ AC_CHECK_FUNC(

 AC_CHECK_FUNCS(prctl mallinfo mallinfo2 getpass closefrom getpwnam_r getgrnam_r getpwuid_r chown)
 AC_CHECK_FUNCS(fmemopen funopen mmap memrchr setlinebuf strptime dirfd sigwaitinfo explicit_bzero)
+AC_CHECK_FUNCS(posix_spawn posix_spawn_file_actions_addclosefrom_np posix_spawn_file_actions_addinherit_np)
+
+AC_CHECK_DECLS([POSIX_SPAWN_CLOEXEC_DEFAULT], [], [], [[#include <spawn.h>]])

 AC_CHECK_FUNC([syslog], [
 	AC_DEFINE([HAVE_SYSLOG], [], [have syslog(3) and friends])
diff --git a/src/libstrongswan/tests/suites/test_process.c b/src/libstrongswan/tests/suites/test_process.c
index 940da5b8d4..e5b266d57e 100644
--- a/src/libstrongswan/tests/suites/test_process.c
+++ b/src/libstrongswan/tests/suites/test_process.c
@@ -1,4 +1,5 @@
 /*
+ * Copyright (C) 2026 Tobias Brunner
  * Copyright (C) 2014 Martin Willi
  *
  * Copyright (C) secunet Security Networks AG
@@ -17,6 +18,7 @@
 #include "test_suite.h"

 #include <unistd.h>
+#include <fcntl.h>

 #include <utils/process.h>

@@ -75,10 +77,12 @@ START_TEST(test_not_found)
 		"/bin/does-not-exist",
 		NULL
 	};
+	int retval;

 	process = process_start(argv, NULL, NULL, NULL, NULL, TRUE);
-	/* both is acceptable behavior */
-	ck_assert(process == NULL || !process->wait(process, NULL));
+	/* both is acceptable behavior, posix_spawn() might fail with 127 */
+	ck_assert(process == NULL || !process->wait(process, &retval) ||
+			  retval == 127);
 }
 END_TEST

@@ -98,10 +102,11 @@ START_TEST(test_echo)
 	int retval, in, out;
 	char *msg = "test";
 	char buf[strlen(msg) + 1];
+	bool close_all = _i;

 	memset(buf, 0, strlen(msg) + 1);

-	process = process_start(argv, NULL, &in, &out, NULL, TRUE);
+	process = process_start(argv, NULL, &in, &out, NULL, close_all);
 	ck_assert(process != NULL);
 	ck_assert_int_eq(write(in, msg, strlen(msg)), strlen(msg));
 	ck_assert(close(in) == 0);
@@ -131,10 +136,11 @@ START_TEST(test_echo_err)
 	int retval, in, err;
 	char *msg = "a longer test message";
 	char buf[strlen(msg) + 1];
+	bool close_all = _i;

 	memset(buf, 0, strlen(msg) + 1);

-	process = process_start(argv, NULL, &in, NULL, &err, TRUE);
+	process = process_start(argv, NULL, &in, NULL, &err, close_all);
 	ck_assert(process != NULL);
 	ck_assert_int_eq(write(in, msg, strlen(msg)), strlen(msg));
 	ck_assert(close(in) == 0);
@@ -146,6 +152,68 @@ START_TEST(test_echo_err)
 }
 END_TEST

+START_TEST(test_close_all)
+{
+#ifndef WIN32
+	process_t *process;
+	char *argv[] = { "/bin/sh", "-c", NULL, NULL };
+	int extra, err, code;
+	char cmd[64], fd_file[64], buf[BUF_LEN];
+	bool close_all = _i;
+
+	/* extra fd above 2, deliberately without O_CLOEXEC so the child inherits it
+	 * unless close_all closes it */
+	extra = open("/dev/null", O_RDONLY);
+	ck_assert(extra >= 3);
+
+	/* because many shells (e.g. dash) only support single digits for shell
+	 * redirection, we only use this portable approach if the fd fits */
+	if (extra <= 9)
+	{
+		snprintf(cmd, sizeof(cmd), "true <&%d", extra);
+	}
+	else
+	{
+		/* otherwise we try to test the existence of the fd via /dev or /proc
+		 * file system.  however, this is not fully portable as e.g. FreeBSD
+		 * needs fdescfs mounted for /dev/fd populated with fds > 2 and /proc
+		 * is technically also optional on Linux, so we do a pre-check in the
+		 * parent where the fd must exist in one of these locations */
+		snprintf(fd_file, sizeof(fd_file), "/dev/fd/%d", extra);
+		if (access(fd_file, F_OK) != 0)
+		{
+			snprintf(fd_file, sizeof(fd_file), "/proc/self/fd/%d", extra);
+			if (access(fd_file, F_OK) != 0)
+			{
+				close(extra);
+				fail("neither /dev/fd/%d nor /proc/self/fd/%d available, "
+					 "cannot verify close_all", extra, extra);
+				return;
+			}
+		}
+		snprintf(cmd, sizeof(cmd), "test -e %s", fd_file);
+	}
+	argv[2] = cmd;
+
+	process = process_start(argv, NULL, NULL, NULL, &err, close_all);
+	ck_assert(process != NULL);
+	/* drain stderr */
+	ignore_result(read(err, buf, sizeof(buf)));
+	close(err);
+	ck_assert(process->wait(process, &code));
+	if (close_all)
+	{	/* fd must be gone: redirection or check fails, sh exits non-zero */
+		ck_assert(code != 0);
+	}
+	else
+	{	/* fd must survive: redirection or check succeeds, exit 0 */
+		ck_assert_int_eq(code, 0);
+	}
+	close(extra);
+#endif
+}
+END_TEST
+
 START_TEST(test_env)
 {
 	process_t *process;
@@ -215,8 +283,13 @@ Suite *process_suite_create()

 	tc = tcase_create("echo");
 	tcase_set_timeout(tc, 10);
-	tcase_add_test(tc, test_echo);
-	tcase_add_test(tc, test_echo_err);
+	tcase_add_loop_test(tc, test_echo, 0, 2);
+	tcase_add_loop_test(tc, test_echo_err, 0, 2);
+	suite_add_tcase(s, tc);
+
+	tc = tcase_create("close_all");
+	tcase_set_timeout(tc, 10);
+	tcase_add_loop_test(tc, test_close_all, 0, 2);
 	suite_add_tcase(s, tc);

 	tc = tcase_create("env");
diff --git a/src/libstrongswan/utils/process.c b/src/libstrongswan/utils/process.c
index 35e8e5b0f6..dc6306cf28 100644
--- a/src/libstrongswan/utils/process.c
+++ b/src/libstrongswan/utils/process.c
@@ -1,4 +1,5 @@
 /*
+ * Copyright (C) 2026 Tobias Brunner
  * Copyright (C) 2014 Martin Willi
  *
  * Copyright (C) secunet Security Networks AG
@@ -38,11 +39,23 @@ enum {

 #ifndef WIN32

+/* use posix_spawn() if we can close all open fds > 2, either via the
+ * proprietary glibc function or the proprietary macOS/Android flag */
+#if defined(HAVE_POSIX_SPAWN) && \
+	(defined(HAVE_POSIX_SPAWN_FILE_ACTIONS_ADDCLOSEFROM_NP) || \
+	 HAVE_DECL_POSIX_SPAWN_CLOEXEC_DEFAULT)
+#define USE_POSIX_SPAWN 1
+#endif
+
 #include <unistd.h>
 #include <errno.h>
 #include <sys/wait.h>
 #include <signal.h>

+#ifdef USE_POSIX_SPAWN
+#include <spawn.h>
+#endif
+
 /**
  * Private data of an process_t object.
  */
@@ -122,6 +135,110 @@ METHOD(process_t, wait_, bool,
 	return TRUE;
 }

+#ifdef USE_POSIX_SPAWN
+/**
+ * Adds actions to handle the two ends of a pipe appropriately, dup the one
+ * in "from" to "to" and close both ends (unless there is an overlap).
+ */
+static inline int add_pipe_actions(posix_spawn_file_actions_t *actions,
+								   int pipe[PIPE_ENDS], int from, int to)
+{
+	int from_other = (from == PIPE_READ) ? PIPE_WRITE : PIPE_READ, ret = 0;
+
+	if (pipe[from_other] != -1)
+	{
+		ret = posix_spawn_file_actions_addclose(actions,
+												pipe[from_other]);
+	}
+	if (!ret && pipe[from] != -1)
+	{
+		ret = posix_spawn_file_actions_adddup2(actions,
+											   pipe[from], to);
+		if (!ret && pipe[from] != to)
+		{
+			ret = posix_spawn_file_actions_addclose(actions,
+													pipe[from]);
+		}
+	}
+	return ret;
+}
+
+/**
+ * Use posix_spawn() to start the process, which has the advantage of avoiding
+ * several issues with fork(), in particular on macOS where atfork handlers in
+ * system libraries use allocations that can interfere with e.g. ASan's wrappers
+ * and the locks they use.
+ */
+static bool process_spawn(private_process_t *this, char *const argv[],
+						  char *const envp[], bool close_all)
+{
+	posix_spawn_file_actions_t actions;
+	posix_spawnattr_t attr;
+	pid_t pid;
+	int ret = 0;
+
+	if (posix_spawn_file_actions_init(&actions) != 0)
+	{
+		return FALSE;
+	}
+	if (posix_spawnattr_init(&attr) != 0)
+	{
+		posix_spawn_file_actions_destroy(&actions);
+		return FALSE;
+	}
+	if (!ret)
+	{
+		ret = add_pipe_actions(&actions, this->in, PIPE_READ, 0);
+	}
+	if (!ret)
+	{
+		ret = add_pipe_actions(&actions, this->out, PIPE_WRITE, 1);
+	}
+	if (!ret)
+	{
+		ret = add_pipe_actions(&actions, this->err, PIPE_WRITE, 2);
+	}
+	if (!ret && close_all)
+	{
+#ifdef HAVE_POSIX_SPAWN_FILE_ACTIONS_ADDCLOSEFROM_NP
+		ret = posix_spawn_file_actions_addclosefrom_np(&actions, 3);
+#elif HAVE_DECL_POSIX_SPAWN_CLOEXEC_DEFAULT
+		ret = posix_spawnattr_setflags(&attr, POSIX_SPAWN_CLOEXEC_DEFAULT);
+#ifdef HAVE_POSIX_SPAWN_FILE_ACTIONS_ADDINHERIT_NP
+		/* the above includes FDs 0-2 on macOS (but not on Android), so inherit
+		 * them if they are not redirected to preserve the behavior seen on
+		 * other platforms and with the fork fallback */
+		if (!ret && this->in[PIPE_READ] == -1)
+		{
+			ret = posix_spawn_file_actions_addinherit_np(&actions, 0);
+		}
+		if (!ret && this->out[PIPE_WRITE] == -1)
+		{
+			ret = posix_spawn_file_actions_addinherit_np(&actions, 1);
+		}
+		if (!ret && this->err[PIPE_WRITE] == -1)
+		{
+			ret = posix_spawn_file_actions_addinherit_np(&actions, 2);
+		}
+#endif
+#endif
+	}
+	if (!ret)
+	{
+		ret = posix_spawn(&pid, argv[0], &actions, &attr, argv, envp);
+	}
+	posix_spawn_file_actions_destroy(&actions);
+	posix_spawnattr_destroy(&attr);
+	if (ret)
+	{
+		DBG1(DBG_LIB, "spawning process failed: %s", strerror(ret));
+		return FALSE;
+	}
+	this->pid = pid;
+	return TRUE;
+}
+#endif /* USE_POSIX_SPAWN */
+
 /**
  * See header
  */
@@ -159,6 +276,13 @@ process_t* process_start(char *const argv[], char *const envp[],
 		return NULL;
 	}

+#ifdef USE_POSIX_SPAWN
+	if (!process_spawn(this, argv, envp ?: empty, close_all))
+	{
+		process_destroy(this);
+		return NULL;
+	}
+#else
 	this->pid = fork();
 	switch (this->pid)
 	{
@@ -177,6 +301,10 @@ process_t* process_start(char *const argv[], char *const envp[],
 				{
 					raise(SIGKILL);
 				}
+				if (this->in[PIPE_READ] != 0)
+				{
+					close(this->in[PIPE_READ]);
+				}
 			}
 			if (this->out[PIPE_WRITE] != -1)
 			{
@@ -184,6 +312,10 @@ process_t* process_start(char *const argv[], char *const envp[],
 				{
 					raise(SIGKILL);
 				}
+				if (this->out[PIPE_WRITE] != 1)
+				{
+					close(this->out[PIPE_WRITE]);
+				}
 			}
 			if (this->err[PIPE_WRITE] != -1)
 			{
@@ -191,6 +323,10 @@ process_t* process_start(char *const argv[], char *const envp[],
 				{
 					raise(SIGKILL);
 				}
+				if (this->err[PIPE_WRITE] != 2)
+				{
+					close(this->err[PIPE_WRITE]);
+				}
 			}
 			if (close_all)
 			{
@@ -203,26 +339,29 @@ process_t* process_start(char *const argv[], char *const envp[],
 			/* not reached */
 		default:
 			/* parent */
-			close_if(&this->in[PIPE_READ]);
-			close_if(&this->out[PIPE_WRITE]);
-			close_if(&this->err[PIPE_WRITE]);
-			if (in)
-			{
-				*in = this->in[PIPE_WRITE];
-				this->in[PIPE_WRITE] = -1;
-			}
-			if (out)
-			{
-				*out = this->out[PIPE_READ];
-				this->out[PIPE_READ] = -1;
-			}
-			if (err)
-			{
-				*err = this->err[PIPE_READ];
-				this->err[PIPE_READ] = -1;
-			}
-			return &this->public;
+			break;
 	}
+#endif /* !USE_POSIX_SPAWN */
+
+	close_if(&this->in[PIPE_READ]);
+	close_if(&this->out[PIPE_WRITE]);
+	close_if(&this->err[PIPE_WRITE]);
+	if (in)
+	{
+		*in = this->in[PIPE_WRITE];
+		this->in[PIPE_WRITE] = -1;
+	}
+	if (out)
+	{
+		*out = this->out[PIPE_READ];
+		this->out[PIPE_READ] = -1;
+	}
+	if (err)
+	{
+		*err = this->err[PIPE_READ];
+		this->err[PIPE_READ] = -1;
+	}
+	return &this->public;
 }

 /**