From 3fe77bd7088af1e0f5822e12555a4f4221301dbe Mon Sep 17 00:00:00 2001
From: TJ Saunders <tj@castaglia.org>
Date: Sun, 26 Jul 2020 13:38:42 -0700
Subject: [PATCH] Issue #1070: Implement support for Redis 6.x AUTH semantics.

---
 include/redis.h    |  10 ++-
 src/redis.c        | 128 +++++++++++++++++++++++++--
 tests/api/env.c    |   8 +-
 tests/api/redis.c  | 210 ++++++++++++++++++++++++++++++++++++++-------
 tests/api/sets.c   |  24 +++---
 tests/api/str.c    |   8 +-
 tests/api/timers.c |   2 +-
 7 files changed, 334 insertions(+), 56 deletions(-)

diff --git a/include/redis.h b/include/redis.h
index 27f71e454..eae519bfb 100644
--- a/include/redis.h
+++ b/include/redis.h
@@ -1,6 +1,6 @@
 /*
  * ProFTPD - FTP server daemon
- * Copyright (c) 2017 The ProFTPD Project team
+ * Copyright (c) 2017-2020 The ProFTPD Project team
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -55,8 +55,14 @@ int pr_redis_conn_destroy(pr_redis_t *redis);
 int pr_redis_conn_set_namespace(pr_redis_t *redis, module *m,
   const void *prefix, size_t prefixsz);
 
+/* Redis server version. */
+int pr_redis_conn_get_version(pr_redis_t *redis, unsigned int *major_version,
+  unsigned int *minor_version, unsigned int *patch_version);
+
 /* Authenticate to a password-protected Redis server. */
 int pr_redis_auth(pr_redis_t *redis, const char *password);
+int pr_redis_auth2(pr_redis_t *redis, const char *username,
+  const char *password);
 
 /* Select the database used by the Redis server. */
 int pr_redis_select(pr_redis_t *redis, const char *db_idx);
@@ -304,6 +310,8 @@ int pr_redis_sentinel_get_masters(pool *p, pr_redis_t *redis,
 /* For internal use only */
 int redis_set_server(const char *server, int port, unsigned long flags,
   const char *password, const char *db_idx);
+int redis_set_server2(const char *server, int port, unsigned long flags,
+  const char *username, const char *password, const char *db_idx);
 int redis_set_sentinels(array_header *sentinels, const char *name);
 int redis_set_timeouts(unsigned long connect_millis, unsigned long io_millis);
 
diff --git a/src/redis.c b/src/redis.c
index 5bd99623f..cca05bd3f 100644
--- a/src/redis.c
+++ b/src/redis.c
@@ -49,6 +49,11 @@ struct redis_rec {
    */
   unsigned int refcount;
 
+  /* Redis server version. */
+  unsigned int major_version;
+  unsigned int minor_version;
+  unsigned int patch_version;
+
   /* Table mapping modules to their namespaces */
   pr_table_t *namespace_tab;
 };
@@ -59,6 +64,7 @@ static const char *redis_sentinel_master = NULL;
 static const char *redis_server = NULL;
 static int redis_port = -1;
 static unsigned long redis_flags = 0UL;
+static const char *redis_username = NULL;
 static const char *redis_password = NULL;
 static const char *redis_db_idx = NULL;
 
@@ -69,6 +75,8 @@ static unsigned long redis_io_millis = 500;
 
 static const char *trace_channel = "redis";
 
+static const char *get_reply_type(int reply_type);
+
 static void millis2timeval(struct timeval *tv, unsigned long millis) {
   tv->tv_sec = (millis / 1000);
   tv->tv_usec = (millis - (tv->tv_sec * 1000)) * 1000;
@@ -194,6 +202,48 @@ static int ping_server(pr_redis_t *redis) {
   return 0;
 }
 
+static void parse_redis_version(pr_redis_t *redis, redisReply *info) {
+  pool *tmp_pool;
+  unsigned int major, minor, patch;
+  char *text, *version_text;
+
+  if (info->type != REDIS_REPLY_STRING) {
+    pr_trace_msg(trace_channel, 1, "expected STRING reply for INFO, got %s",
+      get_reply_type(info->type));
+    return;
+  }
+
+  tmp_pool = make_sub_pool(redis->pool);
+  pr_pool_tag(tmp_pool, "Redis version parsing pool");
+  text = pstrndup(tmp_pool, info->str, info->len);
+
+  /* Scan the entire INFO string for "redis_version:N.N.N". */
+
+  version_text = strstr(text, "redis_version:");
+  if (version_text == NULL) {
+    pr_trace_msg(trace_channel, 1, "no `redis_version` found in INFO reply");
+    destroy_pool(tmp_pool);
+    return;
+  }
+
+  if (sscanf(version_text, "redis_version:%u.%u.%u", &major, &minor,
+      &patch) == 3) {
+    redis->major_version = major;
+    redis->minor_version = minor;
+    redis->patch_version = patch;
+
+    pr_trace_msg(trace_channel, 9,
+      "parsed Redis version %u (major), %u (minor), %u (patch) out of INFO",
+      redis->major_version, redis->minor_version, redis->patch_version);
+
+  } else {
+    pr_trace_msg(trace_channel, 1, "failed to scan Redis version '%s'",
+      version_text);
+  }
+
+  destroy_pool(tmp_pool);
+}
+
 static int stat_server(pr_redis_t *redis, const char *section) {
   const char *cmd;
   redisReply *reply;
@@ -214,6 +264,18 @@ static int stat_server(pr_redis_t *redis, const char *section) {
       (unsigned long) reply->len);
   }
 
+  if (redis->major_version == 0 &&
+      (strcmp(section, "server") == 0 || strcmp(section, "") == 0)) {
+    /* We are particularly interested in the Redis server version; we key
+     * off of this version to detect when to change our command semantics,
+     * such as for AUTH.
+     *
+     * Thus we parse the server version out of the "server" info, unless
+     * the version is already known.
+     */
+    parse_redis_version(redis, reply);
+  }
+
   freeReplyObject(reply);
   return 0;
 }
@@ -508,7 +570,13 @@ pr_redis_t *pr_redis_conn_new(pool *p, module *m, unsigned long flags) {
   }
 
   if (redis_password != NULL) {
-    res = pr_redis_auth(redis, redis_password);
+    if (redis_username != NULL) {
+      res = pr_redis_auth2(redis, redis_username, redis_password);
+
+    } else {
+      res = pr_redis_auth(redis, redis_password);
+    }
+
     if (res < 0) {
       xerrno = errno;
 
@@ -682,6 +750,36 @@ int pr_redis_conn_set_namespace(pr_redis_t *redis, module *m,
   return 0;
 }
 
+int pr_redis_conn_get_version(pr_redis_t *redis, unsigned int *major_version,
+    unsigned int *minor_version, unsigned int *patch_version) {
+
+  if (redis == NULL) {
+    errno = EINVAL;
+    return -1;
+  }
+
+  if (major_version == NULL &&
+      minor_version == NULL &&
+      patch_version == NULL) {
+    errno = EINVAL;
+    return -1;
+  }
+
+  if (major_version != NULL) {
+    *major_version = redis->major_version;
+  }
+
+  if (minor_version != NULL) {
+    *minor_version = redis->minor_version;
+  }
+
+  if (patch_version != NULL) {
+    *patch_version = redis->patch_version;
+  }
+
+  return 0;
+}
+
 int pr_redis_add(pr_redis_t *redis, module *m, const char *key, void *value,
     size_t valuesz, time_t expires) {
   int res;
@@ -2003,12 +2101,14 @@ int pr_redis_command(pr_redis_t *redis, const array_header *args,
   return 0;
 }
 
-int pr_redis_auth(pr_redis_t *redis, const char *password) {
+int pr_redis_auth2(pr_redis_t *redis, const char *username,
+    const char *password) {
   const char *cmd;
   pool *tmp_pool;
   redisReply *reply;
 
   if (redis == NULL ||
+      username == NULL ||
       password == NULL) {
     errno = EINVAL;
     return -1;
@@ -2019,7 +2119,15 @@ int pr_redis_auth(pr_redis_t *redis, const char *password) {
 
   cmd = "AUTH";
   pr_trace_msg(trace_channel, 7, "sending command: %s", cmd);
-  reply = redisCommand(redis->ctx, "%s %s", cmd, password);
+
+  /* Redis 6.x changed the AUTH semantics, now requiring a username. */
+  if (redis->major_version >= 6) {
+    reply = redisCommand(redis->ctx, "%s %s %s", cmd, username, password);
+
+  } else {
+    reply = redisCommand(redis->ctx, "%s %s", cmd, password);
+  }
+
   reply = handle_reply(redis, cmd, reply);
   if (reply == NULL) {
     pr_trace_msg(trace_channel, 2,
@@ -2053,6 +2161,10 @@ int pr_redis_auth(pr_redis_t *redis, const char *password) {
   return 0;
 }
 
+int pr_redis_auth(pr_redis_t *redis, const char *password) {
+  return pr_redis_auth2(redis, "default", password);
+}
+
 int pr_redis_select(pr_redis_t *redis, const char *db_idx) {
   const char *cmd;
   pool *tmp_pool;
@@ -5632,8 +5744,8 @@ int pr_redis_sentinel_get_masters(pool *p, pr_redis_t *redis,
   return res;
 }
 
-int redis_set_server(const char *server, int port, unsigned long flags,
-    const char *password, const char *db_idx) {
+int redis_set_server2(const char *server, int port, unsigned long flags,
+    const char *username, const char *password, const char *db_idx) {
 
   if (server == NULL) {
     /* By using a port of -2 specifically, we can use this function to
@@ -5649,12 +5761,18 @@ int redis_set_server(const char *server, int port, unsigned long flags,
   redis_server = server;
   redis_port = port;
   redis_flags = flags;
+  redis_username = username;
   redis_password = password;
   redis_db_idx = db_idx;
 
   return 0;
 }
 
+int redis_set_server(const char *server, int port, unsigned long flags,
+    const char *password, const char *db_idx) {
+  return redis_set_server2(server, port, flags, "default", password, db_idx);
+}
+
 int redis_set_sentinels(array_header *sentinels, const char *name) {
 
   if (sentinels != NULL &&
diff --git a/tests/api/env.c b/tests/api/env.c
index ad126385e..b839f2ea4 100644
--- a/tests/api/env.c
+++ b/tests/api/env.c
@@ -1,6 +1,6 @@
 /*
  * ProFTPD - FTP server testsuite
- * Copyright (c) 2008-2015 The ProFTPD Project team
+ * Copyright (c) 2008-2020 The ProFTPD Project team
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -61,11 +61,13 @@ START_TEST (env_get_test) {
   pr_env_unset(p, key);
 
   res = pr_env_get(p, key);
-  fail_unless(res == NULL);
+  fail_unless(res == NULL, "Unexpectedly found value '%s' for key '%s'",
+    res, key);
 
   /* XXX PATH should always be set in the environment, right? */
   res = pr_env_get(p, "PATH");
-  fail_unless(res != NULL);
+  fail_unless(res != NULL, "Failed to get value for 'PATH': %s",
+    strerror(errno));
 
 #else
   res = pr_env_get(p, key);
diff --git a/tests/api/redis.c b/tests/api/redis.c
index 7e95893ee..e44d5a4bd 100644
--- a/tests/api/redis.c
+++ b/tests/api/redis.c
@@ -222,11 +222,44 @@ START_TEST (redis_conn_set_namespace_test) {
 }
 END_TEST
 
+START_TEST (redis_conn_get_version_test) {
+  int res;
+  pr_redis_t *redis;
+  unsigned int major = 0, minor = 0, patch = 0;
+
+  mark_point();
+  res = pr_redis_conn_get_version(NULL, NULL, NULL, NULL);
+  fail_unless(res < 0, "Failed to handle null redis");
+  fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
+    strerror(errno), errno);
+
+  mark_point();
+  redis = pr_redis_conn_new(p, NULL, 0);
+  fail_unless(redis != NULL, "Failed to open connection to Redis: %s",
+    strerror(errno));
+
+  mark_point();
+  res = pr_redis_conn_get_version(redis, NULL, NULL, NULL);
+  fail_unless(res < 0, "Failed to handle null version arguments");
+  fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
+    strerror(errno), errno);
+
+  mark_point();
+  res = pr_redis_conn_get_version(redis, &major, &minor, &patch);
+  fail_unless(res == 0, "Failed to get Redis version: %s", strerror(errno));
+
+  mark_point();
+  res = pr_redis_conn_destroy(redis);
+  fail_unless(res == TRUE, "Failed to close redis: %s", strerror(errno));
+}
+END_TEST
+
 START_TEST (redis_conn_auth_test) {
   int res;
   pr_redis_t *redis;
   const char *text;
   array_header *args;
+  unsigned int major_version = 0;
 
   mark_point();
   res = pr_redis_auth(NULL, NULL);
@@ -245,52 +278,167 @@ START_TEST (redis_conn_auth_test) {
   fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
     strerror(errno), errno);
 
-  text = "password";
-
   /* What happens if we try to AUTH to a non-password-protected Redis?
    * Answer: Redis returns an error indicating that no password is required.
+   *
+   * Note that this behavior changed with Redis 6.x.  In particular, any
+   * "AUTH default ..." command automatically succeeds with Redis 6.x,
+   * regardless of the actual password given.  Sigh.
    */
+
+  mark_point();
+  res = pr_redis_conn_get_version(redis, &major_version, NULL, NULL);
+  fail_unless(res == 0, "Failed to get Redis version: %s", strerror(errno));
+
   mark_point();
+  text = "password";
   res = pr_redis_auth(redis, text);
-  fail_unless(res < 0, "Failed to handle lack of need for authentication");
+
+  if (major_version < 6) {
+    fail_unless(res < 0, "Failed to handle lack of need for authentication");
+    fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
+      strerror(errno), errno);
+
+    /* Use CONFIG SET to require a password. */
+    args = make_array(p, 0, sizeof(char *));
+    *((char **) push_array(args)) = pstrdup(p, "CONFIG");
+    *((char **) push_array(args)) = pstrdup(p, "SET");
+    *((char **) push_array(args)) = pstrdup(p, "requirepass");
+    *((char **) push_array(args)) = pstrdup(p, text);
+
+    mark_point();
+    res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_STATUS);
+    fail_unless(res == 0, "Failed to enable authentication: %s",
+      strerror(errno));
+
+    args = make_array(p, 0, sizeof(char *));
+    *((char **) push_array(args)) = pstrdup(p, "TIME");
+
+    mark_point();
+    res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_ARRAY);
+    fail_unless(res < 0, "Failed to handle required authentication");
+    fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
+      strerror(errno), errno);
+
+    mark_point();
+    res = pr_redis_auth(redis, text);
+    fail_unless(res == 0, "Failed to authenticate client: %s", strerror(errno));
+
+    /* Don't forget to remove the password. */
+    args = make_array(p, 0, sizeof(char *));
+    *((char **) push_array(args)) = pstrdup(p, "CONFIG");
+    *((char **) push_array(args)) = pstrdup(p, "SET");
+    *((char **) push_array(args)) = pstrdup(p, "requirepass");
+    *((char **) push_array(args)) = pstrdup(p, "");
+
+    mark_point();
+    res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_STATUS);
+    fail_unless(res == 0, "Failed to remove password authentication: %s",
+      strerror(errno));
+
+  } else {
+    fail_unless(res == 0, "Failed to handle AUTH command: %s",
+      strerror(errno));
+  }
+
+  mark_point();
+  res = pr_redis_conn_destroy(redis);
+  fail_unless(res == TRUE, "Failed to close redis: %s", strerror(errno));
+}
+END_TEST
+
+START_TEST (redis_conn_auth2_test) {
+  int res;
+  pr_redis_t *redis;
+  const char *username, *password;
+  array_header *args;
+  unsigned int major_version = 0;
+
+  mark_point();
+  res = pr_redis_auth2(NULL, NULL, NULL);
+  fail_unless(res < 0, "Failed to handle null redis");
   fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
     strerror(errno), errno);
 
-  /* Use CONFIG SET to require a password. */
-  args = make_array(p, 0, sizeof(char *));
-  *((char **) push_array(args)) = pstrdup(p, "CONFIG");
-  *((char **) push_array(args)) = pstrdup(p, "SET");
-  *((char **) push_array(args)) = pstrdup(p, "requirepass");
-  *((char **) push_array(args)) = pstrdup(p, text);
+  mark_point();
+  redis = pr_redis_conn_new(p, NULL, 0);
+  fail_unless(redis != NULL, "Failed to open connection to Redis: %s",
+    strerror(errno));
 
   mark_point();
-  res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_STATUS);
-  fail_unless(res == 0, "Failed to enable authentication: %s", strerror(errno));
+  res = pr_redis_auth2(redis, NULL, NULL);
+  fail_unless(res < 0, "Failed to handle null username");
+  fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
+    strerror(errno), errno);
 
-  args = make_array(p, 0, sizeof(char *));
-  *((char **) push_array(args)) = pstrdup(p, "TIME");
+  /* Note: Do NOT use "default" as the initial username; that name has
+   * specific semantics for Redis 6.x and later.
+   */
+  username = "foobar";
 
   mark_point();
-  res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_ARRAY);
-  fail_unless(res < 0, "Failed to handle required authentication");
+  res = pr_redis_auth2(redis, username, NULL);
+  fail_unless(res < 0, "Failed to handle null password");
   fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
     strerror(errno), errno);
 
-  mark_point();
-  res = pr_redis_auth(redis, text);
-  fail_unless(res == 0, "Failed to authenticate client: %s", strerror(errno));
+  /* What happens if we try to AUTH to a non-password-protected Redis?
+   * Answer: Redis returns an error indicating that no password is required.
+   *
+   * Note that this behavior changed with Redis 6.x.  In particular, any
+   * "AUTH default ..." command automatically succeeds with Redis 6.x,
+   * regardless of the actual password given.  Sigh.
+   */
 
-  /* Don't forget to remove the password. */
-  args = make_array(p, 0, sizeof(char *));
-  *((char **) push_array(args)) = pstrdup(p, "CONFIG");
-  *((char **) push_array(args)) = pstrdup(p, "SET");
-  *((char **) push_array(args)) = pstrdup(p, "requirepass");
-  *((char **) push_array(args)) = pstrdup(p, "");
+  mark_point();
+  res = pr_redis_conn_get_version(redis, &major_version, NULL, NULL);
+  fail_unless(res == 0, "Failed to get Redis version: %s", strerror(errno));
 
   mark_point();
-  res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_STATUS);
-  fail_unless(res == 0, "Failed to remove password authentication: %s",
-    strerror(errno));
+  password = "password";
+  res = pr_redis_auth2(redis, username, password);
+  fail_unless(res < 0, "Failed to handle lack of need for authentication");
+  fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
+    strerror(errno), errno);
+
+  if (major_version < 6) {
+    /* Use CONFIG SET to require a password. */
+    args = make_array(p, 0, sizeof(char *));
+    *((char **) push_array(args)) = pstrdup(p, "CONFIG");
+    *((char **) push_array(args)) = pstrdup(p, "SET");
+    *((char **) push_array(args)) = pstrdup(p, "requirepass");
+    *((char **) push_array(args)) = pstrdup(p, password);
+
+    mark_point();
+    res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_STATUS);
+    fail_unless(res == 0, "Failed to enable authentication: %s",
+      strerror(errno));
+
+    args = make_array(p, 0, sizeof(char *));
+    *((char **) push_array(args)) = pstrdup(p, "TIME");
+
+    mark_point();
+    res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_ARRAY);
+    fail_unless(res < 0, "Failed to handle required authentication");
+    fail_unless(errno == EINVAL, "Expected EINVAL (%d), got %s (%d)", EINVAL,
+      strerror(errno), errno);
+
+    mark_point();
+    res = pr_redis_auth2(redis, username, password);
+    fail_unless(res == 0, "Failed to authenticate client: %s", strerror(errno));
+
+    /* Don't forget to remove the password. */
+    args = make_array(p, 0, sizeof(char *));
+    *((char **) push_array(args)) = pstrdup(p, "CONFIG");
+    *((char **) push_array(args)) = pstrdup(p, "SET");
+    *((char **) push_array(args)) = pstrdup(p, "requirepass");
+    *((char **) push_array(args)) = pstrdup(p, "");
+
+    mark_point();
+    res = pr_redis_command(redis, args, PR_REDIS_REPLY_TYPE_STATUS);
+    fail_unless(res == 0, "Failed to remove password authentication: %s",
+      strerror(errno));
+  }
 
   mark_point();
   res = pr_redis_conn_destroy(redis);
@@ -1987,7 +2135,7 @@ START_TEST (redis_hash_keys_test) {
   mark_point();
   res = pr_redis_hash_keys(p, redis, &m, key, &fields);
   fail_unless(res == 0, "Failed to handle existing fields: %s", strerror(errno));
-  fail_unless(fields != NULL);
+  fail_unless(fields != NULL, "Failed to get hash fields");
   fail_unless(fields->nelts == 2, "Expected 2, got %u", fields->nelts);
 
   (void) pr_redis_remove(redis, &m, key);
@@ -2074,7 +2222,7 @@ START_TEST (redis_hash_values_test) {
   mark_point();
   res = pr_redis_hash_values(p, redis, &m, key, &values);
   fail_unless(res == 0, "Failed to handle existing values: %s", strerror(errno));
-  fail_unless(values != NULL);
+  fail_unless(values != NULL, "Failed to get hash values");
   fail_unless(values->nelts == 2, "Expected 2, got %u", values->nelts);
 
   (void) pr_redis_remove(redis, &m, key);
@@ -2161,7 +2309,7 @@ START_TEST (redis_hash_getall_test) {
   mark_point();
   res = pr_redis_hash_getall(p, redis, &m, key, &hash);
   fail_unless(res == 0, "Failed to handle existing fields: %s", strerror(errno));
-  fail_unless(hash != NULL);
+  fail_unless(hash != NULL, "Failed to get hash");
   res = pr_table_count(hash);
   fail_unless(res == 2, "Expected 2, got %d", res);
 
@@ -4743,7 +4891,9 @@ Suite *tests_get_redis_suite(void) {
   tcase_add_test(testcase, redis_conn_new_test);
   tcase_add_test(testcase, redis_conn_get_test);
   tcase_add_test(testcase, redis_conn_set_namespace_test);
+  tcase_add_test(testcase, redis_conn_get_version_test);
   tcase_add_test(testcase, redis_conn_auth_test);
+  tcase_add_test(testcase, redis_conn_auth2_test);
   tcase_add_test(testcase, redis_conn_select_test);
   tcase_add_test(testcase, redis_conn_reconnect_test);
   tcase_add_test(testcase, redis_command_test);
diff --git a/tests/api/sets.c b/tests/api/sets.c
index 9f3deafc2..9459454f2 100644
--- a/tests/api/sets.c
+++ b/tests/api/sets.c
@@ -1,6 +1,6 @@
 /*
  * ProFTPD - FTP server testsuite
- * Copyright (c) 2008-2011 The ProFTPD Project team
+ * Copyright (c) 2008-2020 The ProFTPD Project team
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -97,20 +97,20 @@ START_TEST (set_create_test) {
   fail_unless(errno == EPERM, "Failed to set errno to EPERM");
 
   res = xaset_create(p, NULL);
-  fail_unless(res != NULL);
+  fail_unless(res != NULL, "Expected non-null result");
   fail_unless(res->pool == p, "Expected %p, got %p", p, res->pool);
 
   permanent_pool = make_sub_pool(p);
 
   res = xaset_create(NULL, NULL);
-  fail_unless(res != NULL);
+  fail_unless(res != NULL, "Expected non-null result");
   fail_unless(res->pool == permanent_pool, "Expected %p, got %p",
     permanent_pool, res->pool);
   fail_unless(res->xas_compare == NULL, "Expected NULL, got %p",
     res->xas_compare);
 
   res = xaset_create(p, (XASET_COMPARE) item_cmp);
-  fail_unless(res != NULL);
+  fail_unless(res != NULL, "Expected non-null result");
   fail_unless(res->pool == p, "Expected %p, got %p", p, res->pool);
   fail_unless(res->xas_compare == (XASET_COMPARE) item_cmp,
     "Expected %p, got %p", item_cmp, res->xas_compare);
@@ -355,12 +355,12 @@ START_TEST (set_remove_test) {
   fail_unless(res == 0, "Failed to add item2");
 
   member = (xasetmember_t *) item1;
-  fail_unless(member->next == NULL);
-  fail_unless(member->prev != NULL);
+  fail_unless(member->next == NULL, "Expected member->next to be null");
+  fail_unless(member->prev != NULL, "Expected member->prev to not be null");
 
   member = (xasetmember_t *) item2;
-  fail_unless(member->next != NULL);
-  fail_unless(member->prev == NULL);
+  fail_unless(member->next != NULL, "Expected member->next to not be null");
+  fail_unless(member->prev == NULL, "Expected member->prev to be null");
 
   member = set->xas_list;
   fail_unless(member == (xasetmember_t *) item2,
@@ -371,8 +371,8 @@ START_TEST (set_remove_test) {
     strerror(errno));
 
   member = (xasetmember_t *) item2;
-  fail_unless(member->next == NULL);
-  fail_unless(member->prev == NULL);
+  fail_unless(member->next == NULL, "Expected member->next to be null");
+  fail_unless(member->prev == NULL, "Expected member->prev to be null");
 
   member = set->xas_list;
   fail_unless(member == (xasetmember_t *) item1,
@@ -383,8 +383,8 @@ START_TEST (set_remove_test) {
     strerror(errno));
 
   member = (xasetmember_t *) item1;
-  fail_unless(member->next == NULL);
-  fail_unless(member->prev == NULL);
+  fail_unless(member->next == NULL, "Expected member->next to be null");
+  fail_unless(member->prev == NULL, "Expected member->prev to be null");
 
   member = set->xas_list;
   fail_unless(member == NULL, "Expected list to be empty, got %p", member);
diff --git a/tests/api/str.c b/tests/api/str.c
index 9dce95820..050f5c563 100644
--- a/tests/api/str.c
+++ b/tests/api/str.c
@@ -1539,10 +1539,10 @@ START_TEST (uid2str_test) {
   const char *res;
 
   res = pr_uid2str(NULL, (uid_t) 1);
-  fail_unless(strcmp(res, "1") == 0);
+  fail_unless(strcmp(res, "1") == 0, "Expected '1', got '%s'", res);
 
   res = pr_uid2str(NULL, (uid_t) -1);
-  fail_unless(strcmp(res, "-1") == 0);
+  fail_unless(strcmp(res, "-1") == 0, "Expected '-1', got '%s'", res);
 }
 END_TEST
 
@@ -1550,10 +1550,10 @@ START_TEST (gid2str_test) {
   const char *res;
 
   res = pr_gid2str(NULL, (gid_t) 1);
-  fail_unless(strcmp(res, "1") == 0);
+  fail_unless(strcmp(res, "1") == 0, "Expected '1', got '%s'", res);
 
   res = pr_gid2str(NULL, (gid_t) -1);
-  fail_unless(strcmp(res, "-1") == 0);
+  fail_unless(strcmp(res, "-1") == 0, "Expected '-1', got '%s'", res);
 }
 END_TEST
 
diff --git a/tests/api/timers.c b/tests/api/timers.c
index 99b6348e9..0616a7989 100644
--- a/tests/api/timers.c
+++ b/tests/api/timers.c
@@ -157,7 +157,7 @@ START_TEST (timer_remove_test) {
   int res;
 
   res = pr_timer_remove(0, NULL);
-  fail_unless(res == 0);
+  fail_unless(res == 0, "Failed to remove timer: %s", strerror(errno));
 
   res = pr_timer_add(1, 0, NULL, timers_test_cb, "test");
   fail_unless(res == 0, "Failed to add timer (%d): %s", res, strerror(errno));
