On 2/16/23 00:03, Eric Blake wrote:
On Wed, Feb 15, 2023 at 03:11:39PM +0100, Laszlo Ersek wrote:
> Don't try to test async-signal-safety, but strive to exercise as many as
> possible paths through nbd_internal_execvpe_init() and
> nbd_internal_fork_safe_execvpe().
>
> Signed-off-by: Laszlo Ersek <lersek(a)redhat.com>
> ---
> lib/test-fork-safe-execvpe.c | 117 +++++++++
> lib/Makefile.am | 10 +
> lib/test-fork-safe-execvpe.sh | 270 ++++++++++++++++++++
> .gitignore | 1 +
> 4 files changed, 398 insertions(+)
>
> diff --git a/lib/test-fork-safe-execvpe.c b/lib/test-fork-safe-execvpe.c
> new file mode 100644
> index 000000000000..09b91102b613
> --- /dev/null
> +++ b/lib/test-fork-safe-execvpe.c
> +
> +/* This is a perror() replacement that makes the error message more
> + * machine-readable, for a select few error numbers. Do not use it as a general
> + * error() replacement, only upon nbd_internal_execvpe_init() and
> + * nbd_internal_fork_safe_execvpe() failure.
> + */
> +static void
> +xperror (const char *s)
Handy for the .sh counterpart that reads expected output.
> +
> +int
> +main (int argc, char **argv)
> +{
> + struct execvpe ctx;
> + const char *prog_file;
> + string_vector prog_argv;
> + size_t i;
> +
> + if (argc < 3) {
> + fprintf (stderr, "%1$s: usage: %1$s program-to-exec argv0 ...\n",
argv[0]);
> + return EXIT_FAILURE;
> + }
> +
> + prog_file = argv[1];
> +
> + /* For the argv of the program to execute, we need to drop our argv[0] (= our
> + * own name) and argv[1] (= the program we need to execute), and to tack on a
> + * terminating null pointer. Note that "argc" does not include the
terminating
> + * NULL.
> + */
> + prog_argv = (string_vector)empty_vector;
> + if (string_vector_reserve (&prog_argv, argc - 2 + 1) == -1) {
> + perror ("string_vector_reserve");
> + return EXIT_FAILURE;
> + }
> +
> + for (i = 2; i < argc; ++i)
> + (void)string_vector_append (&prog_argv, argv[i]);
> + (void)string_vector_append (&prog_argv, NULL);
I don't know if I would have used the cast-to-void. But it doesn't
hurt (this is a unit test and you are documenting that we are
basically ignoring append errors as unlikely), and at this point it's
more effort to rip than out than leave well enough alone.
s/unlikely/impossible/, per successful string_vector_reserve() above.
> +
> + if (nbd_internal_execvpe_init (&ctx, prog_file, prog_argv.len) == -1) {
> + xperror ("nbd_internal_execvpe_init");
> + goto reset_prog_argv;
> + }
> +
> + /* Print out the generated candidates. */
> + for (i = 0; i < ctx.pathnames.len; ++i)
> + (void)fprintf (stdout, "%s\n", ctx.pathnames.ptr[i]);
Peering into the internals. Naughty for normal code, but perfectly
acceptable for the unit test.
Well put :)
> +
> + if (fflush (stdout) == EOF) {
> + perror ("fflush");
> + goto uninit_execvpe;
> + }
> +
> + (void)nbd_internal_fork_safe_execvpe (&ctx, &prog_argv, environ);
> + xperror ("nbd_internal_fork_safe_execvpe");
> + /* fall through */
> +
> +uninit_execvpe:
> + nbd_internal_execvpe_uninit (&ctx);
> +
> +reset_prog_argv:
> + string_vector_reset (&prog_argv);
> +
> + return EXIT_FAILURE;
> +}
> +++ b/lib/test-fork-safe-execvpe.sh
> +
> +set -e
> +
> +# Determine the absolute pathname of the execvpe helper binary. The
"realpath"
> +# utility is not in POSIX, but Linux, FreeBSD and OpenBSD all have it.
not in POSIX yet, but see
https://www.austingroupbugs.net/view.php?id=1457
Very cool! Thanks!
> +bname=$(basename -- "$0" .sh)
> +dname=$(dirname -- "$0")
> +execvpe=$(realpath -- "$dname/$bname")
> +
> +# This is an elaborate way to control the PATH variable around the $execvpe
> +# helper binary as narrowly as possible.
> +#
> +# If $1 is "_", then the $execvpe helper binary is invoked with PATH
unset.
> +# Otherwise, the binary is invoked with PATH set to $1.
> +#
> +# $2 and onward are passed to $execvpe; note that $2 becomes *both*
> +# "program-to-exec" for the helper *and* argv[0] for the program executed
by the
> +# helper.
> +#
> +# The command itself (including the PATH setting) is written to "cmd" (for
error
> +# reporting purposes only); the standard output and error are saved in
"out" and
> +# "err" respectively; the exit status is written to "status".
This function
> +# should never fail; if it does, then that's a bug in this unit test script, or
> +# the disk is full etc.
> +run()
Looks nice.
> +
> +# Create a temporary directory and change the working directory to it.
> +tmpd=$(mktemp -d)
> +trap 'rm -r -- "$tmpd"' EXIT
Our testsuite has cleanup_fn in functions.sh to make this sort of
cleanup work more robust (more than just on EXIT).
(1) cleanup_fn is in "tests/functions.sh", but this unit test is in lib/.
Hm... still, I see lots of
. ../tests/functions.sh
lines across various test cases. So, based on that, and for uniformity's
sake, I can use cleanup_fn here, indeed.
(2) But, what do you mean by "more than just on EXIT"? In
"functions.sh", I see:
trap _run_cleanup_hooks INT QUIT TERM EXIT ERR
From these, INT, TERM, EXIT and ERR should *already* be covered by
just
EXIT; ERR in particular because of "set -e" in this script.
And regarding QUIT, I disagree that the cleanup handlers should run upon
QUIT. QUIT ("Terminal quit signal") is a signal specifically designed
for interactive abnormal termination, without cleaning up, in practice
even including a core dump -- that is, for debugging purposes. I do use
Ctrl-\ on occasion, for debugging.
https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap01.html...
Any temporary file created by the implementation shall be removed by
the implementation upon a utility's successful exit, exit because of
errors, or before termination by any of the SIGHUP, SIGINT, or
SIGTERM signals, unless specified otherwise by the utility
description.
Receipt of the SIGQUIT signal should generally cause termination
(unless in some debugging mode) that would bypass any attempted
recovery actions.
> +cd "$tmpd"
> +
> +# If the "file" parameter of execvpe() is an empty string, then we must
fail --
> +# in nbd_internal_execvpe_init() -- regardless of PATH.
> +run _ ""; init_fail ENOENT
> +run "" ""; init_fail ENOENT
> +run . ""; init_fail ENOENT
> +
> +# Create subdirectories for triggering non-fatal internal error conditions of
> +# execvpe(). (Almost) every subdirectory will contain one entry, called
"f".
> +#
> +# Create a directory that's empty.
> +mkdir empty
> +
> +# Create a directory with a named pipe (FIFO) in it.
> +mkdir fifo
> +mkfifo fifo/f
> +
> +# Create a directory with a non-executable file in it.
> +mkdir nxregf
> +touch nxregf/f
> +
> +# Create a symlink loop.
> +ln -s symlink symlink
There are two places where ELOOP could matter: both in PATH (symlink/f
can't resolve, regardless of the binary name we are looking up) and in
the binary (./symlink or PATH=. symlink). Looking ahead, I'm seeing
your tests of symlink as a directory name, but not as a binary name.
But I don't know if adding it would increase coverage of your code, so
much as coverage of the underlying execve() call.
Exactly -- what particular internal step of execve() leads to execve()
returning -1/ELOOP doesn't matter here.
> +
> +# Create a directory with a (most likely) binary executable in it.
> +mkdir bin
> +expr_pathname=$(command -p -v expr)
> +cp -- "$expr_pathname" bin/f
> +
> +# Create a directory with an executable shell script that does not contain a
> +# shebang (#!). The script will print $0 and $1, and not depend on PATH for it.
> +mkdir sh
> +printf "command -p printf '%%s %%s\\\\n' \"\$0\"
\"\$1\"\\n" >sh/f
> +chmod u+x sh/f
Based on your reactions to my comments on 09/29 about handling empty
PATH= the same as PATH=., and on whether to allow the child process to
chdir() and/or deal with a PATH element or binary name such as "+s"
that could impact /bin/sh's behavior, there may be other cases you
want to add to the testsuite.
I agree, and that's another reason why I wouldn't like to modify the
behavior on PATH='' :)
Writing the present patch was the single most time consuming / difficult
step across the entire series.
> +
> +# In the tests below, invoke each "f" such that the "file"
parameter of
> +# execvpe() contain a <slash> character.
> +#
> +# Therefore, PATH does not matter. Set it to the empty string. (Which in this
> +# implementation would cause nbd_internal_execvpe_init() to fail with ENOENT, if
> +# the "file" parameter didn't contain a <slash>.)
> +run "" empty/f; execve_fail empty/f ENOENT
> +run "" fifo/f; execve_fail fifo/f EACCES
> +run "" nxregf/f; execve_fail nxregf/f EACCES
> +run "" nxregf/f/; execve_fail nxregf/f/ ENOTDIR
> +run "" symlink/f; execve_fail symlink/f ELOOP
> +
> +# This runs "expr 1 + 1".
> +run "" bin/f 1 + 1; success bin/f,2
> +
> +# This triggers the ENOEXEC branch in nbd_internal_fork_safe_execvpe().
> +# nbd_internal_fork_safe_execvpe() will first try
> +#
> +# execve("sh/f", {"sh/f", "arg1", NULL}, envp)
> +#
> +# hitting ENOEXEC. Then it will successfully call
> +#
> +# execve("/bin/sh", {"sh/f", "sh/f",
"arg1", NULL}, envp)
> +#
> +# The shell script will get "sh/f" for $0 and "arg1" for $1, and
print those
> +# out.
> +run "" sh/f arg1; success sh/f,"sh/f arg1"
> +
> +# In the tests below, the "file" parameter of execvpe() will not contain
a
> +# <slash> character.
> +#
> +# Show that PATH matters that way -- first, trigger an ENOENT in
> +# nbd_internal_execvpe_init() by setting PATH to the empty string.
> +run "" expr 1 + 1; init_fail ENOENT
Another spot that might need tweaking based on your reaction to my
comments on the previous patch.
> +
> +# Fall back to confstr(_CS_PATH) in nbd_internal_execvpe_init(), by unsetting
> +# PATH. Verify the generated candidates by invoking "getconf PATH" here,
and
> +# appending "/expr" to each prefix.
> +expected_output=$(
> + path=$(command -p getconf PATH)
We may have to check that getconf works, or skip the test if it
doesn't (POSIX says it should exist, but not all the world is POSIX).
Can be done as a followup if CI shows a failure.
Hm, good point!
Our README file says, under requirements: "Linux, FreeBSD or OpenBSD".
Both BSDs seem to know about "getconf":
https://man.openbsd.org/getconf.1
https://man.freebsd.org/cgi/man.cgi?query=getconf
Let's hope CI will be happy. :)
> + IFS=:
> + for p in $path; do
> + printf '%s/%s\n' $p expr
> + done
> + command -p expr 1 + 1
> +)
> +run _ expr 1 + 1; success "${expected_output//$'\n'/,}"
> +
> +# Continue with tests where the "file" parameter of execvpe() does not
contain a
> +# <slash> character, but now set PATH to explicit prefix lists.
> +#
> +# Show that, if the last candidate fails execve() with an error number that
> +# would not be fatal otherwise, we do get that error number.
> +run empty:fifo:nxregf:symlink f
> +execve_fail empty/f,fifo/f,nxregf/f,symlink/f ELOOP
> +
> +# Put a single prefix in PATH, such that it lead to a successful execution. This
leads
I disagree (as a non-native speaker); I meant to express a singular
third person imperative, with subjunctive mood:
https://en.wikipedia.org/wiki/English_subjunctive
> +# exercises two things at the same time: (a) that nbd_internal_execvpe_init()
> +# produces *one* candidate (i.e., that no <colon> is seen), and (b) that
> +# nbd_internal_fork_safe_execvpe() succeeds for the *last* candidate. Repeat the
> +# test with "expr" (called "f" under "bin") and the
shell script (called "f"
> +# under "sh", triggering the ENOEXEC branch).
> +run bin f 1 + 1; success bin/f,2
> +run sh f arg1; success sh/f,"sh/f arg1"
> +
> +# Demonstrate that the order of candidates matters. The first invocation finds
> +# "expr" (called "f" under "bin"), the second
invocation finds the shell script
> +# under "sh" (triggering the ENOEXEC branch).
> +run empty:bin:sh f 1 + 1; success empty/f,bin/f,sh/f,2
> +run empty:sh:bin f arg1; success empty/f,sh/f,bin/f,"sh/f arg1"
> +
> +# Check the expansion of zero-length prefixes in PATH to ".", plus the
> +# (non-)insertion of the "/" separator.
> +run a/: f; execve_fail a/f,./f ENOENT
> +run :a/ f; execve_fail ./f,a/f ENOENT
> +run : f; execve_fail ./f,./f ENOENT
> +pushd bin
> +run : f 1 + 1; success ./f,./f,2
> +popd
> +run :a/:::b/: f; execve_fail ./f,a/f,./f,./f,b/f,./f ENOENT
Overall a nice unit test.
Reviewed-by: Eric Blake <eblake(a)redhat.com>
Thanks!
Laszlo