/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*-
 *
 * Copyright 2025 GNOME Foundation, Inc.
 *
 * SPDX-License-Identifier: LGPL-2.1-or-later
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 *
 * Authors:
 *  - Philip Withnall <pwithnall@gnome.org>
 */

#include "config.h"

#include <arpa/inet.h>
#include <cdb.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <glib.h>
#include <glib/gstdio.h>
#include <gio/gio.h>
#include <locale.h>
#include <netdb.h>
#include <nss.h>
#include <pwd.h>
#include <stdio.h>

static int (*real_open) (const char *, int, ...);
static char *mock_filter_list_path = NULL;
static enum
{
  GETPWUID_VALID_USER,
  GETPWUID_EMPTY_USERNAME,
  GETPWUID_USER_NOT_FOUND,
  GETPWUID_ERROR_EIO,
} mock_getpwuid_behaviour = GETPWUID_VALID_USER;
static enum
{
  OPEN_PASSTHROUGH,
  OPEN_ERROR_ENOENT,
  OPEN_ERROR_EACCES,
} mock_open_behaviour = OPEN_PASSTHROUGH;

int
open (const char *pathname,
      int         flags,
      ...)
{
  va_list va;
  mode_t mode = 0;

  if (real_open == NULL)
    real_open = dlsym (RTLD_NEXT, "open");

  va_start (va, flags);
  if (flags & (O_CREAT | O_TMPFILE))
    mode = va_arg (va, mode_t);
  va_end (va);

  /* Override the path for the compiled filter list file */
  if (mock_filter_list_path != NULL &&
      strcmp (pathname, "/var/lib/malcontent-nss/filter-lists/test-user") == 0)
    pathname = mock_filter_list_path;

  /* Allow overriding the behaviour. */
  switch (mock_open_behaviour)
    {
    case OPEN_PASSTHROUGH:
      return real_open (pathname, flags, mode);
    case OPEN_ERROR_ENOENT:
      errno = ENOENT;
      return -1;
    case OPEN_ERROR_EACCES:
      errno = EACCES;
      return -1;
    default:
      g_assert_not_reached ();
    }
}

int
getpwuid_r (uid_t                    uid,
            struct passwd *restrict  pwd,
            char                     buf[],
            size_t                   buflen,
            struct passwd **restrict result)
{
  const char *test_user_username = "test-user";

  /* We expect the current user */
  g_assert (uid == getuid ());

  /* Override the username lookup for the compiled filter list file */
  if (buflen <= strlen (test_user_username))
    {
      *result = NULL;
      return ERANGE;
    }

  switch (mock_getpwuid_behaviour)
    {
    case GETPWUID_VALID_USER:
      strncpy (buf, test_user_username, buflen);
      pwd->pw_name = buf;
      pwd->pw_uid = uid;
      /* don’t bother with the other fields for now */
      *result = pwd;

      return 0;
    case GETPWUID_EMPTY_USERNAME:
      strncpy (buf, "", buflen);
      pwd->pw_name = buf;
      pwd->pw_uid = uid;
      /* don’t bother with the other fields for now */
      *result = pwd;

      return 0;
    case GETPWUID_USER_NOT_FOUND:
      *result = NULL;
      return 0;
    case GETPWUID_ERROR_EIO:
      *result = NULL;
      return EIO;
    default:
      g_assert_not_reached ();
    }
}

static void
assert_lookup_is_sinkhole (const char *hostname)
{
  const struct in_addr sinkhole_addr = { .s_addr = 0 };
  const struct in6_addr sinkhole_addr6 = { .s6_addr = { 0, } };
  struct addrinfo *res = NULL;
  struct addrinfo hints;

  memset (&hints, 0, sizeof (hints));
  hints.ai_family = AF_UNSPEC;  /* IPv4 or IPv6 */

  g_assert_cmpint (getaddrinfo (hostname, NULL, &hints, &res), ==, 0);

  for (struct addrinfo *i = res; i != NULL; i = i->ai_next)
    {
      if (i->ai_addr->sa_family == AF_INET)
        {
          const struct sockaddr_in *p = (const struct sockaddr_in *) i->ai_addr;
          g_assert_cmpmem (&(p->sin_addr), sizeof (p->sin_addr), &sinkhole_addr, sizeof (sinkhole_addr));
        }
      else if (i->ai_addr->sa_family == AF_INET6)
        {
          const struct sockaddr_in6 *p = (const struct sockaddr_in6 *) i->ai_addr;
          g_assert_cmpmem (&(p->sin6_addr), sizeof (p->sin6_addr), &sinkhole_addr6, sizeof (sinkhole_addr6));
        }
    }

  freeaddrinfo (res);

  /* And query again with AI_CANONNAME set, to test that. */
  hints.ai_flags |= AI_CANONNAME;
  g_assert_cmpint (getaddrinfo (hostname, NULL, &hints, &res), ==, 0);
  g_assert_cmpstr (res->ai_canonname, ==, hostname);
  freeaddrinfo (res);
}

static void
assert_lookup_is_not_sinkhole (const char *hostname)
{
  struct addrinfo *res = NULL;
  struct addrinfo hints;

  memset (&hints, 0, sizeof (hints));
  hints.ai_family = AF_UNSPEC;  /* IPv4 or IPv6 */

  /* If it’s not blocked by nss-malcontent, we expect lookup to fail as ‘not
   * found’, since no other modules are loaded for the `hosts` NSS database. */
  g_assert_cmpint (getaddrinfo (hostname, NULL, &hints, &res), ==, EAI_NODATA);
  g_assert_null (res);
}

static void
assert_lookup_is_redirect (const char *hostname,
                           const char *expected_redirected_hostname)
{
  /* These addresses are hardcoded in the nss-hardcoded module. */
  const char *redirected_addr_str = "1.2.3.4";
  const char *redirected_addr6_str = "2001:0DB8:AC10:FE01::";

  struct in_addr redirected_addr;
  struct in6_addr redirected_addr6;
  struct addrinfo *res = NULL;
  struct addrinfo hints;

  memset (&hints, 0, sizeof (hints));
  hints.ai_family = AF_UNSPEC;  /* IPv4 or IPv6 */
  hints.ai_flags = AI_CANONNAME;

  g_assert_cmpint (getaddrinfo (hostname, NULL, &hints, &res), ==, 0);

  g_assert_cmpint (inet_pton (AF_INET, redirected_addr_str, &redirected_addr), ==, 1);
  g_assert_cmpint (inet_pton (AF_INET6, redirected_addr6_str, &redirected_addr6), ==, 1);

  g_assert_cmpstr (res->ai_canonname, ==, expected_redirected_hostname);

  for (struct addrinfo *i = res; i != NULL; i = i->ai_next)
    {
      if (i->ai_addr->sa_family == AF_INET)
        {
          const struct sockaddr_in *p = (const struct sockaddr_in *) i->ai_addr;
          g_assert_cmpmem (&(p->sin_addr), sizeof (p->sin_addr), &redirected_addr, sizeof (redirected_addr));
        }
      else if (i->ai_addr->sa_family == AF_INET6)
        {
          const struct sockaddr_in6 *p = (const struct sockaddr_in6 *) i->ai_addr;
          g_assert_cmpmem (&(p->sin6_addr), sizeof (p->sin6_addr), &redirected_addr6, sizeof (redirected_addr6));
        }
    }

  freeaddrinfo (res);
}

static void
assert_load_nss_malcontent_module (void)
{
  char path[PATH_MAX + 1];
  struct addrinfo hints;
  void *handle = NULL;
  g_autofree char *expected_malcontent_module_dir = NULL;
  g_autofree char *canonical_expected_malcontent_module_dir = NULL;
  g_autofree char *expected_hardcoded_module_dir = NULL;
  g_autofree char *canonical_expected_hardcoded_module_dir = NULL;

  /* Force NSS to use the `malcontent` module for all `hosts` database requests
   * (with the `hardcoded` mock test module as a fallback).
   *
   * This essentially overrides the `hosts` line of `/etc/nsswitch.conf` to be
   * `hosts: malcontent hardcoded`.
   *
   * That’s good, because it’s impossible to override the `/etc/nsswitch.conf`
   * file in-process otherwise. Because NSS is part of glibc, the dynamic linker
   * is not involved in resolving the `fopen()` syscall, so we can’t hook that.
   * The only way I know of to override `/etc/nsswitch.conf` is to run the test
   * inside a bwrap wrapper which bind-mounts over the top of the system
   * `/etc/nsswitch.conf` file. That can’t be done from within the test process
   * without `CAP_SYS_ADMIN` privileges; it would rely on `bwrap` being setuid.
   *
   * Thanks to this article for the idea: https://ldpreload.com/blog/testing-glibc-nsswitch
   *
   * With the `libnss_malcontent.so.2` module loaded, we need to override
   * `open()` and `getpwuid_r()` (above) to make it use a compiled web filter
   * file of our choice.
   *
   * `libnss_hardcoded.so.2` needs no overrides.
   */
  __nss_configure_lookup ("hosts", "malcontent hardcoded");

  /* Do a lookup to force the module to load. */
  memset (&hints, 0, sizeof (hints));
  hints.ai_family = AF_UNSPEC;  /* IPv4 or IPv6 */
  getaddrinfo ("example.com", NULL, &hints, NULL);

  /* Check the right module was loaded. */
  expected_malcontent_module_dir = g_test_build_filename (G_TEST_BUILT, "..", NULL);
  canonical_expected_malcontent_module_dir = g_canonicalize_filename (expected_malcontent_module_dir, "/");

  handle = dlopen ("libnss_malcontent.so.2", RTLD_LAZY | RTLD_NOLOAD);
  g_assert_nonnull (handle);
  g_assert_no_errno (dlinfo (handle, RTLD_DI_ORIGIN, &path));
  g_assert_cmpstr (path, ==, canonical_expected_malcontent_module_dir);
  dlclose (handle);

  expected_hardcoded_module_dir = g_test_build_filename (G_TEST_BUILT, ".", NULL);
  canonical_expected_hardcoded_module_dir = g_canonicalize_filename (expected_hardcoded_module_dir, "/");

  handle = dlopen ("libnss_hardcoded.so.2", RTLD_LAZY | RTLD_NOLOAD);
  g_assert_nonnull (handle);
  g_assert_no_errno (dlinfo (handle, RTLD_DI_ORIGIN, &path));
  g_assert_cmpstr (path, ==, canonical_expected_hardcoded_module_dir);
  dlclose (handle);
}

typedef GFile MockFilterListHandle;

static void
mock_filter_list_handle_destroy (MockFilterListHandle *handle)
{
  GFile *file = G_FILE (handle);

  g_file_delete (file, NULL, NULL);
  g_clear_object (&file);

  g_clear_pointer (&mock_filter_list_path, g_free);
}

typedef struct
{
  const char *key;  /* a hostname, or `*` or a hostname prefixed with `~` */
  const char *value;  /* (nullable); NULL to block, non-NULL to redirect */
} FilterListEntry;

G_DEFINE_AUTOPTR_CLEANUP_FUNC (MockFilterListHandle, mock_filter_list_handle_destroy)

/* Build a test compiled filter file, and then set it to be used by the loaded
 * NSS module by setting `mock_filter_list_path.
 *
 * @filter_list_entries is NULL-terminated */
static MockFilterListHandle *
set_mock_filter_list (const FilterListEntry *filter_list_entries)
{
  struct cdb_make cdbm;
  g_autofd int test_web_filter_fd = -1;
  g_autofree char *test_web_filter_path = NULL;
  g_autoptr(GError) local_error = NULL;

  test_web_filter_fd = g_file_open_tmp ("test-web-filter-XXXXXX", &test_web_filter_path, &local_error);
  g_assert_no_error (local_error);
  g_assert_no_errno (cdb_make_start (&cdbm, test_web_filter_fd));

  for (size_t i = 0; filter_list_entries[i].key != NULL; i++)
    {
      g_assert_no_errno (cdb_make_put (&cdbm,
                                       filter_list_entries[i].key,
                                       strlen (filter_list_entries[i].key),
                                       filter_list_entries[i].value,
                                       (filter_list_entries[i].value != NULL) ? strlen (filter_list_entries[i].value) : 0,
                                       CDB_PUT_REPLACE));
    }

  g_assert_no_errno (cdb_make_finish (&cdbm));
  g_assert_no_errno (fsync (test_web_filter_fd));

  g_clear_pointer (&mock_filter_list_path, g_free);
  mock_filter_list_path = g_strdup (test_web_filter_path);

  return (MockFilterListHandle *) g_file_new_for_path (test_web_filter_path);
}

static void
test_nss_malcontent_basic_blocklist (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;

  g_test_summary ("Test a basic blocklist filter");

  assert_load_nss_malcontent_module ();

  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { "duckduckgo.com", NULL },
    { NULL, NULL },
  });

  /* Try a lookup */
  assert_lookup_is_sinkhole ("duckduckgo.com");
  assert_lookup_is_not_sinkhole ("not-blocked.com");
}

static void
test_nss_malcontent_basic_allowlist (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;

  g_test_summary ("Test a basic allowlist filter");

  assert_load_nss_malcontent_module ();

  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { "*", NULL },
    { "~duckduckgo.com", NULL },
    { NULL, NULL },
  });

  /* Try a lookup */
  assert_lookup_is_sinkhole ("google.com");
  assert_lookup_is_not_sinkhole ("duckduckgo.com");
}

static void
test_malcontent_use_application_dns (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;

  g_test_summary ("Test that the DNS-over-HTTPS canary is disabled by the NSS module");

  assert_load_nss_malcontent_module ();

  /* Build an empty filter list file. This should still force DNS-over-HTTPS to
   * be disabled. */
  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { NULL, NULL },
  });

  /* Try a lookup */
  assert_lookup_is_sinkhole ("use-application-dns.net");
}

static void
test_nss_malcontent_redirect (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;

  g_test_summary ("Test a basic redirect filter");

  assert_load_nss_malcontent_module ();

  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { "duckduckgo.com", "always-resolves.com" },
    { "google.com", "never-resolves.com" },
    { NULL, NULL },
  });

  /* Try a lookup. A redirect pointing to a domain which can’t be resolved
   * should be treated as blocked. */
  assert_lookup_is_redirect ("duckduckgo.com", "always-resolves.com");
  assert_lookup_is_sinkhole ("google.com");
  assert_lookup_is_not_sinkhole ("not-blocked.com");
}

static void
test_nss_malcontent_unsupported_address_family (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;

  g_test_summary ("Test that unsupported address families are ignored");

  assert_load_nss_malcontent_module ();

  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { NULL, NULL },
  });

  /* Try a lookup with an unsupported address family. At the moment this won’t
   * actually exercise the branch we want to test in `nss-malcontent.c` because
   * glibc rejects all families which aren’t `AF_UNSPEC`, `AF_INET` or
   * `AF_INET6` (see
   * https://elixir.bootlin.com/glibc/glibc-2.42/source/nss/getaddrinfo.c#L2318).
   * Since that behaviour could change in future, we keep the test here. */
  struct addrinfo *res = NULL;
  struct addrinfo hints;

  memset (&hints, 0, sizeof (hints));
  hints.ai_family = AF_UNIX;

  g_assert_cmpint (getaddrinfo ("test.com", NULL, &hints, &res), ==, EAI_FAMILY);
}

static void
test_nss_malcontent_too_long_hostname (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;
  char very_long_hostname[256 + 1];

  g_test_summary ("Test that too long hostnames are ignored");

  assert_load_nss_malcontent_module ();

  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { NULL, NULL },
  });

  /* Try a lookup with a hostname longer than 255 bytes. */
  struct addrinfo *res = NULL;
  struct addrinfo hints;

  memset (&hints, 0, sizeof (hints));
  hints.ai_family = AF_UNSPEC;  /* IPv4 or IPv6 */

  memset (very_long_hostname, 'a', sizeof (very_long_hostname));
  very_long_hostname[sizeof (very_long_hostname) - 1] = '\0';

  /* We’d expect EAI_MEMORY here, but NSS seems to reserve that for its own
   * internal memory allocation problems, and squares an ENOMEM returned by an
   * NSS module to just be EAI_NODATA. */
  g_assert_cmpint (getaddrinfo (very_long_hostname, NULL, &hints, &res), ==, EAI_NODATA);
}

static void
test_nss_malcontent_getpwuid_errors (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;

  g_test_summary ("Test that errors returned by getpwuid_r() are handled gracefully");

  assert_load_nss_malcontent_module ();

  /* Set a fairly standard mock filter list, with example.com blocked. */
  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { "example.com", NULL },
    { NULL, NULL },
  });

  /* Try a lookup where internally calling getpwuid_r() fails somehow. */
  struct
    {
      int getpwuid_behaviour;
      int expected_gai_errno;
    }
  vectors[] =
    {
      { GETPWUID_EMPTY_USERNAME, EAI_NODATA },
      { GETPWUID_USER_NOT_FOUND, EAI_NODATA },
      { GETPWUID_ERROR_EIO, EAI_NODATA },
    };

  for (size_t i = 0; i < G_N_ELEMENTS (vectors); i++)
    {
      struct addrinfo *res = NULL;
      struct addrinfo hints;

      memset (&hints, 0, sizeof (hints));
      hints.ai_family = AF_UNSPEC;  /* IPv4 or IPv6 */

      mock_getpwuid_behaviour = vectors[i].getpwuid_behaviour;

      /* Look up example.com. If the mock filter was successfully loaded, this
       * should be blocked; but the filter should *not* be loaded because
       * getpwuid_r() will fail. The code should handle this failure gracefully. */
      g_assert_cmpint (getaddrinfo ("example.com", NULL, &hints, &res), ==, vectors[i].expected_gai_errno);
    }

  mock_getpwuid_behaviour = GETPWUID_VALID_USER;
}

static void
test_nss_malcontent_open_errors (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;

  g_test_summary ("Test that errors returned by open() are handled gracefully");

  assert_load_nss_malcontent_module ();

  /* Set a fairly standard mock filter list, with example.com blocked. */
  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { "example.com", NULL },
    { NULL, NULL },
  });

  /* Try a lookup where internally calling open() fails somehow. */
  struct
    {
      int open_behaviour;
      int expected_gai_errno;
    }
  vectors[] =
    {
      { OPEN_ERROR_ENOENT, EAI_NODATA },
      { OPEN_ERROR_EACCES, EAI_NODATA },
    };

  for (size_t i = 0; i < G_N_ELEMENTS (vectors); i++)
    {
      struct addrinfo *res = NULL;
      struct addrinfo hints;

      memset (&hints, 0, sizeof (hints));
      hints.ai_family = AF_UNSPEC;  /* IPv4 or IPv6 */

      mock_open_behaviour = vectors[i].open_behaviour;

      /* Look up example.com. If the mock filter was successfully loaded, this
       * should be blocked; but the filter should *not* be loaded because
       * open() will fail. The code should handle this failure gracefully. */
      g_assert_cmpint (getaddrinfo ("example.com", NULL, &hints, &res), ==, vectors[i].expected_gai_errno);
    }

  mock_open_behaviour = OPEN_PASSTHROUGH;
}

static void
test_nss_malcontent_aliases (void)
{
  g_autoptr(MockFilterListHandle) handle = NULL;
  struct hostent *host;

  g_test_summary ("Test that the returned struct hostent has a non-NULL h_aliases field");

  assert_load_nss_malcontent_module ();

  handle = set_mock_filter_list ((const FilterListEntry[]) {
    { "duckduckgo.com", NULL },
    { NULL, NULL },
  });

  /* Try a lookup. Because this is the old gethostbyname2() function, it’s not
   * thread-safe. */
  host = gethostbyname2 ("duckduckgo.com", AF_INET6);
  g_assert_nonnull (host);

  g_assert_cmpstr (host->h_name, ==, "duckduckgo.com");
  g_assert_nonnull (host->h_aliases);
  g_assert_null (host->h_aliases[0]);
  g_assert_cmpint (host->h_addrtype, ==, AF_INET6);
  g_assert_cmpint (host->h_length, ==, sizeof (struct in6_addr));
  g_assert_nonnull (host->h_addr_list);
  g_assert_nonnull (host->h_addr_list[0]);
}

int
main (int    argc,
      char **argv)
{
  int retval;

  setlocale (LC_ALL, "");
  g_test_init (&argc, &argv, G_TEST_OPTION_ISOLATE_DIRS, NULL);

  g_test_add_func ("/nss-malcontent/basic-blocklist", test_nss_malcontent_basic_blocklist);
  g_test_add_func ("/nss-malcontent/basic-allowlist", test_nss_malcontent_basic_allowlist);
  g_test_add_func ("/nss-malcontent/use-application-dns", test_malcontent_use_application_dns);
  g_test_add_func ("/nss-malcontent/redirect", test_nss_malcontent_redirect);
  g_test_add_func ("/nss-malcontent/unsupported-address-family", test_nss_malcontent_unsupported_address_family);
  g_test_add_func ("/nss-malcontent/too-long-hostname", test_nss_malcontent_too_long_hostname);
  g_test_add_func ("/nss-malcontent/getpwuid-errors", test_nss_malcontent_getpwuid_errors);
  g_test_add_func ("/nss-malcontent/open-errors", test_nss_malcontent_open_errors);
  g_test_add_func ("/nss-malcontent/aliases", test_nss_malcontent_aliases);

  retval = g_test_run ();

  g_free (mock_filter_list_path);

  return retval;
}
