tests: add regression test for prompt cancellation on client disconnect

Verify that connection_hangup_handler fires before drop_reference (guards
against the use-after-free in issue #125) and that the server remains
responsive after a client disconnect so a second password request is
queued normally (guards against the hang in issue #126).

The test requires root for the SO_PEERCRED uid check and exits with
status 77 (meson SKIP) when not running as root.
This commit is contained in:
pilotstew 2026-05-02 15:37:15 -05:00
parent 4d3fe676aa
commit a65537388b
3 changed files with 386 additions and 0 deletions

View file

@ -102,3 +102,4 @@ endif
if get_option('docs')
subdir('docs')
endif
subdir('tests')

24
tests/meson.build Normal file
View file

@ -0,0 +1,24 @@
test_inc = [
config_h_inc,
include_directories('../src'),
include_directories('../src/libply'),
]
# Compile ply-boot-server.c directly into the test so we can verify the
# connection_hangup_handler callback mechanism without needing a full plymouthd.
test_server_connection_hangup = executable(
'test-server-connection-hangup',
files(
'test-server-connection-hangup.c',
'../src/ply-boot-server.c',
),
include_directories: test_inc,
c_args: plymouthd_cflags,
dependencies: [libply_dep],
)
# Exit code 77 means SKIP (test requires root for SO_PEERCRED uid check).
test('server-connection-hangup',
test_server_connection_hangup,
timeout: 15,
)

View file

@ -0,0 +1,361 @@
/* test-server-connection-hangup.c
*
* Regression test for issues #125 (use-after-free on client kill) and #126
* (password prompt not dismissed when non-interactive unlock completes).
*
* The fix adds a connection_hangup_handler callback to ply_boot_server_t that
* fires *before* the connection reference is dropped, giving the caller a
* window to cancel pending prompts while the connection pointer is still valid.
*
* This test requires root because plymouthd verifies SO_PEERCRED credentials
* before processing password requests. When not running as root it exits with
* status 77 so meson reports the result as SKIP rather than FAIL.
*
* Two scenarios are verified:
*
* Test 1 hangup callback fires with valid connection pointer
* A client sends a password request then disconnects without answering.
* The test asserts that connection_hangup_handler is called with the same
* connection pointer that ask_for_password_handler received, and that the
* pending trigger can be safely pulled from within the callback (proving
* the connection is still live when the handler fires).
*
* Test 2 server remains responsive after first client disconnects
* After the first client disconnects and the pending prompt is cancelled,
* a second client successfully gets its password request queued. This
* directly guards against the "prompt remains on screen forever" regression
* described in issue #126.
*/
#include <assert.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/wait.h>
#include <unistd.h>
#include "ply-boot-protocol.h"
#include "ply-boot-server.h"
#include "ply-event-loop.h"
#include "ply-trigger.h"
#define TEST_SKIP_EXIT_CODE 77
#define TEST_TIMEOUT_SECONDS 10
/* -------------------------------------------------------------------------
* Shared test state
* ---------------------------------------------------------------------- */
typedef struct
{
bool ask1_called;
bool ask2_called;
bool hangup1_called;
ply_boot_connection_t *ask1_connection;
ply_boot_connection_t *hangup1_connection;
ply_trigger_t *ask1_trigger;
ply_event_loop_t *loop;
int loop_exit_code;
} test_state_t;
/* -------------------------------------------------------------------------
* Server callbacks
* ---------------------------------------------------------------------- */
static void
on_ask_for_password (void *user_data,
const char *prompt,
ply_trigger_t *answer,
ply_boot_connection_t *connection,
ply_boot_server_t *server)
{
test_state_t *state = user_data;
if (!state->ask1_called) {
/* First password request: stash trigger, do not answer.
* Simulates booster waiting for user to type while a token
* is simultaneously unlocking the volume. */
state->ask1_called = true;
state->ask1_connection = connection;
state->ask1_trigger = answer;
} else {
/* Second password request arrives after first client disconnected. */
state->ask2_called = true;
/* Pull the trigger with NULL so the server can clean up and the
* event loop can exit. */
ply_trigger_pull (answer, NULL);
ply_event_loop_exit (state->loop, 0);
}
}
static void
on_hangup (void *user_data,
ply_boot_connection_t *connection,
ply_boot_server_t *server)
{
test_state_t *state = user_data;
if (connection != state->ask1_connection)
return; /* not the connection we're tracking */
state->hangup1_called = true;
state->hangup1_connection = connection;
/* Cancel the pending prompt by pulling the trigger with NULL.
* This is the same sequence performed by on_connection_hangup in
* main.c and is safe here because the connection is still live
* (connection_hangup_handler fires *before* drop_reference). */
if (state->ask1_trigger != NULL) {
ply_trigger_pull (state->ask1_trigger, NULL);
state->ask1_trigger = NULL;
}
/* Do not exit the loop yet; wait for the second client. */
}
/* -------------------------------------------------------------------------
* Raw socket helpers
* ---------------------------------------------------------------------- */
static const char *
get_test_socket_path (void)
{
const char *path = getenv ("PLY_BOOT_SOCKET_PATH");
if (path != NULL && path[0] != '\0')
return path;
return PLY_BOOT_PROTOCOL_TRIMMED_ABSTRACT_SOCKET_PATH;
}
static int
connect_to_test_socket (void)
{
const char *path = get_test_socket_path ();
struct sockaddr_un addr;
socklen_t addrlen;
int fd;
fd = socket (AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0)
return -1;
memset (&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
/* Trimmed-abstract encoding: leading NUL + path string, address
* length covers only the bytes actually used (not the full array). */
strncpy (addr.sun_path + 1, path, sizeof(addr.sun_path) - 2);
addrlen = (socklen_t) (offsetof (struct sockaddr_un, sun_path) + 1 + strlen (path));
if (connect (fd, (struct sockaddr *) &addr, addrlen) < 0) {
close (fd);
return -1;
}
return fd;
}
/* Build and send a PLY_BOOT_PROTOCOL_REQUEST_TYPE_PASSWORD frame.
* Frame layout: [cmd '\002' uint8_size data_with_nul] */
static bool
send_password_request (int fd,
const char *prompt)
{
uint8_t frame[3 + 255]; /* argument size field is uint8, so max 255 bytes */
size_t plen = strlen (prompt) + 1; /* NUL-terminated argument */
if (plen > 255)
return false;
frame[0] = (uint8_t) PLY_BOOT_PROTOCOL_REQUEST_TYPE_PASSWORD[0]; /* '*' */
frame[1] = '\002'; /* argument follows */
frame[2] = (uint8_t) plen;
memcpy (frame + 3, prompt, plen);
return write (fd, frame, 3 + plen) == (ssize_t) (3 + plen);
}
/* -------------------------------------------------------------------------
* Child process: client behaviour
*
* The child drives two connections in sequence so the parent's event loop
* can observe both scenarios without any inter-process synchronisation
* beyond the natural ordering imposed by connection lifetimes.
* ---------------------------------------------------------------------- */
static void
run_client (void)
{
int fd;
/* Give the server a moment to start listening. */
usleep (50 * 1000);
/* --- Client 1: sends password request, then disconnects ---------- */
fd = connect_to_test_socket ();
if (fd < 0) {
fprintf (stderr, "client: could not connect to test socket: %m\n");
exit (1);
}
if (!send_password_request (fd, "Passphrase (client 1): ")) {
fprintf (stderr, "client: write failed: %m\n");
exit (1);
}
/* Brief pause so the server processes the request before we close. */
usleep (150 * 1000);
/* Close without answering — this is the scenario that triggered #125
* and #126: the asking process (e.g. booster) exits after a token
* unlocks the volume, leaving the Plymouth password dialog orphaned. */
close (fd);
/* --- Client 2: arrives after client 1's prompt was cancelled ------ */
usleep (150 * 1000);
fd = connect_to_test_socket ();
if (fd < 0) {
fprintf (stderr, "client: could not reconnect: %m\n");
exit (1);
}
if (!send_password_request (fd, "Passphrase (client 2): ")) {
fprintf (stderr, "client: second write failed: %m\n");
exit (1);
}
/* Keep connection open until the server answers or we time out. */
usleep (500 * 1000);
close (fd);
exit (0);
}
/* -------------------------------------------------------------------------
* Alarm handler safety net so the test never hangs in CI
* ---------------------------------------------------------------------- */
static void
on_alarm (int sig __attribute__((unused)))
{
fprintf (stderr, "FAIL: test timed out after %d seconds\n",
TEST_TIMEOUT_SECONDS);
exit (1);
}
/* -------------------------------------------------------------------------
* main
* ---------------------------------------------------------------------- */
int
main (void)
{
test_state_t state = { 0 };
ply_boot_server_t *server;
ply_event_loop_t *loop;
char socket_path[64];
pid_t child;
int child_status;
int exit_code = 0;
if (getuid () != 0) {
fprintf (stdout, "SKIP: test requires root (SO_PEERCRED uid check)\n");
return TEST_SKIP_EXIT_CODE;
}
signal (SIGALRM, on_alarm);
alarm (TEST_TIMEOUT_SECONDS);
/* Unique per-run abstract socket path avoids conflicts with a running
* plymouthd and between parallel test executions. */
snprintf (socket_path, sizeof(socket_path),
"/test/ply-hangup-%d", (int) getpid ());
setenv ("PLY_BOOT_SOCKET_PATH", socket_path, 1);
loop = ply_event_loop_new ();
state.loop = loop;
server = ply_boot_server_new (
NULL, /* update */
NULL, /* change_mode */
NULL, /* system_update */
on_ask_for_password, /* ask_for_password */
NULL, /* ask_question */
NULL, /* display_message */
NULL, /* hide_message */
NULL, /* watch_for_keystroke */
NULL, /* ignore_keystroke */
NULL, /* progress_pause */
NULL, /* progress_unpause */
NULL, /* show_splash */
NULL, /* hide_splash */
NULL, /* newroot */
NULL, /* system_initialized */
NULL, /* error */
NULL, /* deactivate */
NULL, /* reactivate */
NULL, /* quit */
NULL, /* has_active_vt */
NULL, /* reload */
on_hangup, /* connection_hangup — the new callback */
&state);
if (!ply_boot_server_listen (server)) {
fprintf (stderr, "FAIL: could not listen on '%s': %m\n", socket_path);
return 1;
}
ply_boot_server_attach_to_event_loop (server, loop);
child = fork ();
if (child < 0) {
fprintf (stderr, "FAIL: fork failed: %m\n");
return 1;
}
if (child == 0)
run_client (); /* does not return */
/* Run until on_ask_for_password (client 2) calls ply_event_loop_exit,
* or until the alarm fires. */
ply_event_loop_run (loop);
waitpid (child, &child_status, 0);
/* --- Assertions --------------------------------------------------- */
if (!state.ask1_called) {
fprintf (stderr, "FAIL [test 1]: ask_for_password_handler not called for client 1\n");
exit_code = 1;
}
if (!state.hangup1_called) {
fprintf (stderr, "FAIL [test 1]: connection_hangup_handler not called when client 1 disconnected\n");
exit_code = 1;
}
if (state.hangup1_connection != state.ask1_connection) {
fprintf (stderr, "FAIL [test 1]: hangup fired for wrong connection (got %p, want %p)\n",
(void *) state.hangup1_connection,
(void *) state.ask1_connection);
exit_code = 1;
}
if (!state.ask2_called) {
fprintf (stderr, "FAIL [test 2]: ask_for_password_handler not called for client 2 — "
"server stuck after first client disconnect (#126)\n");
exit_code = 1;
}
if (!WIFEXITED (child_status) || WEXITSTATUS (child_status) != 0) {
fprintf (stderr, "FAIL: client child exited abnormally (status %d)\n", child_status);
exit_code = 1;
}
if (exit_code == 0) {
fprintf (stdout, "PASS: connection_hangup_handler fires before drop_reference (#125)\n");
fprintf (stdout, "PASS: server remains responsive after client disconnect (#126)\n");
}
ply_boot_server_free (server);
ply_event_loop_free (loop);
alarm (0);
return exit_code;
}