/* -*- 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 <cdb.h>
#include <glib.h>
#include <glib-object.h>
#include <glib-unix.h>
#include <glib/gi18n-lib.h>
#include <gio/gio.h>
#include <libmalcontent/malcontent.h>
#include <libsoup/soup.h>
#include <pwd.h>
#include <sys/file.h>

#include "filter-list.h"
#include "filter-updater.h"


static void mct_filter_updater_dispose (GObject *object);
static void mct_filter_updater_get_property (GObject    *object,
                                             guint       property_id,
                                             GValue     *value,
                                             GParamSpec *pspec);
static void mct_filter_updater_set_property (GObject      *object,
                                             guint         property_id,
                                             const GValue *value,
                                             GParamSpec   *pspec);

/* https://datatracker.ietf.org/doc/html/rfc2181#section-11 not including trailing nul */
#define HOSTNAME_MAX 255 /* bytes */

G_DEFINE_QUARK (MctFilterUpdaterError, mct_filter_updater_error)

/**
 * MctFilterUpdater:
 *
 * Processing class which updates the compiled filter for one or more users.
 *
 * Updating the compiled filters involves:
 *
 *  1. Downloading all the filter lists for the users being updated. Users may
 *     re-use the same filter lists, in which case they are only downloaded
 *     once. Downloaded filter lists are cached.
 *  2. For each user, the filter rules from all their filter lists are combined
 *     into a unified list, which is then compiled into a tinycdb database for
 *     that user.
 *  3. The compiled tinycdb database is atomically replaced over the user’s
 *     current database. If the user is currently logged in, any programs which
 *     do name resolution will reload the database via a file change
 *     notification, and will start using the new database immediately.
 *  4. If the filter lists are being updated for *all* users (if `filter_uid` is
 *     zero in the call to
 *     [method@Malcontent.FilterUpdater.update_filters_async]) then old cached
 *     filter lists are cleared from the download cache.
 *
 * The tinycdb database is what’s read by the NSS module which implements DNS
 * filtering.
 *
 * In terms of file storage and permissions, two directories are used:
 *  - `$CACHE_DIRECTORY` (typically `/var/cache/malcontent-webd`), used to store
 *    downloaded filter lists. Only accessible to the `malcontent-webd` user.
 *  - `$STATE_DIRECTORY` (typically `/var/cache/malcontent-webd`, with a symlink
 *    to it from `/var/cache/malcontent-nss` for the NSS module to read), used
 *    to store the compiled filter lists in the `filter-lists` subdirectory.
 *    Writable by the `malcontent-webd` user, and world readable.
 *
 * These are [provided by systemd](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=).
 *
 * Ideally we’d have a file permissions setup where each
 * `/var/cache/malcontent-webd/filter-lists/$user` file is readable by
 * `malcontent-webd` and `$user` (and no other users), but not modifiable by any
 * user except `malcontent-webd`. And `/var/cache/malcontent-webd/filter-lists`
 * is not listable by any user except `malcontent-webd`. Unfortunately that’s
 * not possible, so all files are owned by `malcontent-webd` and are world
 * readable.
 *
 * Since: 0.14.0
 */
struct _MctFilterUpdater
{
  GObject parent;

  MctManager *policy_manager;  /* (owned) (not nullable) */
  MctUserManager *user_manager;  /* (owned) (not nullable) */
  GFile *state_directory;  /* (owned) (not nullable) */
  GFile *cache_directory;  /* (owned) (not nullable) */

  gboolean update_in_progress;
};

typedef enum
{
  PROP_POLICY_MANAGER = 1,
  PROP_USER_MANAGER,
  PROP_STATE_DIRECTORY,
  PROP_CACHE_DIRECTORY,
} MctFilterUpdaterProperty;

static GParamSpec *props[PROP_CACHE_DIRECTORY + 1] = { NULL, };

G_DEFINE_TYPE (MctFilterUpdater, mct_filter_updater, G_TYPE_OBJECT)

static void
mct_filter_updater_class_init (MctFilterUpdaterClass *klass)
{
  GObjectClass *object_class = (GObjectClass *) klass;

  object_class->dispose = mct_filter_updater_dispose;
  object_class->get_property = mct_filter_updater_get_property;
  object_class->set_property = mct_filter_updater_set_property;

  /**
   * MctFilterUpdater:policy-manager: (not nullable)
   *
   * Parental controls policy manager.
   *
   * This provides access to the users’ web filtering settings.
   *
   * Since: 0.14.0
   */
  props[PROP_POLICY_MANAGER] =
      g_param_spec_object ("policy-manager", NULL, NULL,
                           MCT_TYPE_MANAGER,
                           G_PARAM_READWRITE |
                           G_PARAM_CONSTRUCT_ONLY |
                           G_PARAM_STATIC_STRINGS);

  /**
   * MctFilterUpdater:user-manager: (not nullable)
   *
   * User manager providing access to the system’s user database.
   *
   * This must have already been asynchronously loaded using
   * [method@Malcontent.UserManager.load_async] before the filter updater is
   * run.
   *
   * Since: 0.14.0
   */
  props[PROP_USER_MANAGER] =
      g_param_spec_object ("user-manager", NULL, NULL,
                           MCT_TYPE_USER_MANAGER,
                           G_PARAM_READWRITE |
                           G_PARAM_CONSTRUCT_ONLY |
                           G_PARAM_STATIC_STRINGS);

  /**
   * MctFilterUpdater:state-directory: (not nullable)
   *
   * Directory to store persistent state in.
   *
   * The compiled tinycdb databases are stored beneath this directory.
   *
   * Since: 0.14.0
   */
  props[PROP_STATE_DIRECTORY] =
      g_param_spec_object ("state-directory", NULL, NULL,
                           G_TYPE_FILE,
                           G_PARAM_READWRITE |
                           G_PARAM_CONSTRUCT_ONLY |
                           G_PARAM_STATIC_STRINGS);

  /**
   * MctFilterUpdater:cache-directory: (not nullable)
   *
   * Directory to store cached data in.
   *
   * The downloaded filter list URI files are cached beneath this directory.
   *
   * Since: 0.14.0
   */
  props[PROP_CACHE_DIRECTORY] =
      g_param_spec_object ("cache-directory", NULL, NULL,
                           G_TYPE_FILE,
                           G_PARAM_READWRITE |
                           G_PARAM_CONSTRUCT_ONLY |
                           G_PARAM_STATIC_STRINGS);

  g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
}

static void
mct_filter_updater_init (MctFilterUpdater *self)
{
}

static void
mct_filter_updater_dispose (GObject *object)
{
  MctFilterUpdater *self = MCT_FILTER_UPDATER (object);

  g_assert (!self->update_in_progress);

  g_clear_object (&self->policy_manager);
  g_clear_object (&self->user_manager);
  g_clear_object (&self->state_directory);
  g_clear_object (&self->cache_directory);

  /* Chain up to the parent class */
  G_OBJECT_CLASS (mct_filter_updater_parent_class)->dispose (object);
}

static void
mct_filter_updater_get_property (GObject    *object,
                                 guint       property_id,
                                 GValue     *value,
                                 GParamSpec *pspec)
{
  MctFilterUpdater *self = MCT_FILTER_UPDATER (object);

  switch ((MctFilterUpdaterProperty) property_id)
    {
    case PROP_POLICY_MANAGER:
      g_value_set_object (value, self->policy_manager);
      break;
    case PROP_USER_MANAGER:
      g_value_set_object (value, self->user_manager);
      break;
    case PROP_STATE_DIRECTORY:
      g_value_set_object (value, self->state_directory);
      break;
    case PROP_CACHE_DIRECTORY:
      g_value_set_object (value, self->cache_directory);
      break;
    default:
      g_assert_not_reached ();
    }
}

static void
mct_filter_updater_set_property (GObject      *object,
                                 guint         property_id,
                                 const GValue *value,
                                 GParamSpec   *pspec)
{
  MctFilterUpdater *self = MCT_FILTER_UPDATER (object);

  switch ((MctFilterUpdaterProperty) property_id)
    {
    case PROP_POLICY_MANAGER:
      /* Construct only. */
      g_assert (self->policy_manager == NULL);
      self->policy_manager = g_value_dup_object (value);
      break;
    case PROP_USER_MANAGER:
      /* Construct only */
      g_assert (self->user_manager == NULL);
      self->user_manager = g_value_dup_object (value);
      break;
    case PROP_STATE_DIRECTORY:
      /* Construct only. */
      g_assert (self->state_directory == NULL);
      self->state_directory = g_value_dup_object (value);
      break;
    case PROP_CACHE_DIRECTORY:
      /* Construct only. */
      g_assert (self->cache_directory == NULL);
      self->cache_directory = g_value_dup_object (value);
      break;
    default:
      g_assert_not_reached ();
    }
}

typedef struct
{
  uid_t uid;
  char *username;  /* (not nullable) (owned) */
  MctWebFilter *web_filter;  /* (nullable) (owned) */
} UserData;

static void
user_data_clear (UserData *data)
{
  g_clear_pointer (&data->username, g_free);
  g_clear_pointer (&data->web_filter, mct_web_filter_unref);
}

typedef struct
{
  uid_t filter_uid;
  GArray *user_datas;  /* (nullable) (owned) (element-type UserData) */
  GPtrArray *filter_uris;  /* (nullable) (owned) (element-type utf8) */
  GFile *filter_lists_dir;  /* (owned) */
  int filter_lists_dir_fd;  /* (owned) */
} UpdateFiltersData;

static void
update_filters_data_free (UpdateFiltersData *data)
{
  g_clear_pointer (&data->user_datas, g_array_unref);
  g_clear_pointer (&data->filter_uris, g_ptr_array_unref);
  g_clear_object (&data->filter_lists_dir);
  g_clear_fd (&data->filter_lists_dir_fd, NULL);
  g_free (data);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (UpdateFiltersData, update_filters_data_free)

static void calculate_users_cb (GObject      *object,
                                GAsyncResult *result,
                                void         *user_data);

static void
calculate_users_async (MctFilterUpdater    *self,
                       uid_t                filter_uid,
                       GCancellable        *cancellable,
                       GAsyncReadyCallback  callback,
                       void                *user_data)
{
  g_autoptr(GTask) task = NULL;

  task = g_task_new (self, cancellable, callback, user_data);
  g_task_set_source_tag (task, calculate_users_async);
  g_task_set_task_data (task, GUINT_TO_POINTER (filter_uid), NULL);

  /* Enumerate which users might have web filtering enabled on their account,
   * and filter by @uid (zero means no filtering).
   * Don’t bother to actually check whether each user has web filtering enabled;
   * that will be done by the caller anyway. */
  g_assert (mct_user_manager_get_is_loaded (self->user_manager));

  /* Now the users have been loaded, list and filter them. */
  mct_user_manager_get_all_users_async (self->user_manager, cancellable,
                                        calculate_users_cb, g_steal_pointer (&task));
}

static void
calculate_users_cb (GObject      *object,
                    GAsyncResult *result,
                    void         *user_data)
{
  MctUserManager *user_manager = MCT_USER_MANAGER (object);
  g_autoptr(GTask) task = G_TASK (g_steal_pointer (&user_data));
  uid_t filter_uid = GPOINTER_TO_UINT (g_task_get_task_data (task));
  g_autoptr(GArray) user_datas = NULL;
  g_autoptr(MctUserArray) users = NULL;
  size_t n_users = 0;
  g_autoptr(GError) local_error = NULL;

  users = mct_user_manager_get_all_users_finish (user_manager, result, &n_users, &local_error);
  if (users == NULL)
    {
      g_task_return_error (task, g_steal_pointer (&local_error));
      return;
    }

  user_datas = g_array_sized_new (FALSE, FALSE, sizeof (UserData), n_users);
  g_array_set_clear_func (user_datas, (GDestroyNotify) user_data_clear);

  for (size_t i = 0; i < n_users; i++)
    {
      MctUser *user = users[i];

      /* Always ignore root. */
      if (mct_user_get_uid (user) == 0)
        continue;

      if (filter_uid == 0 || mct_user_get_uid (user) == filter_uid)
        {
          const uid_t uid = mct_user_get_uid (user);
          const char *username = mct_user_get_username (user);

          g_array_append_val (user_datas, ((const UserData) {
            .uid = uid,
            .username = g_strdup (username),
            .web_filter = NULL,  /* set later */
          }));
        }
    }

  g_task_return_pointer (task, g_steal_pointer (&user_datas), (GDestroyNotify) g_array_unref);
}

static GArray *
calculate_users_finish (MctFilterUpdater  *self,
                        GAsyncResult      *result,
                        GError           **error)
{
  return g_task_propagate_pointer (G_TASK (result), error);
}

static void
update_filters_task_weak_notify_cb (void    *user_data,
                                    GObject *where_the_task_was)
{
  MctFilterUpdater *self = MCT_FILTER_UPDATER (user_data);

  g_assert (self->update_in_progress);
  self->update_in_progress = FALSE;
}

/* We use on-disk cache files (rather than, for example, `O_TMPFILE` files) so
 * that we can use HTTP caching headers to reduce re-downloads of the filter
 * files if they haven’t changed since we last checked them. */
static GFile *
cache_file_for_uri (MctFilterUpdater *self,
                    const char       *uri)
{
  /* Use the D-Bus escaping functions because they result in a string which is
   * alphanumeric with underscores, i.e. no slashes. */
  g_autofree char *escaped_uri = g_dbus_escape_object_path (uri);
  return g_file_get_child (self->cache_directory, escaped_uri);
}

static GFile *
get_filter_lists_dir (MctFilterUpdater *self)
{
  return g_file_get_child (self->state_directory, "filter-lists");
}

static GFile *
tmp_file_for_user (MctFilterUpdater *self,
                   const UserData   *user)
{
  g_autofree char *tmp_basename = NULL;
  g_autoptr(GFile) filter_lists_dir = get_filter_lists_dir (self);

  g_assert (user->username != NULL);
  tmp_basename = g_strconcat (user->username, "~", NULL);

  return g_file_get_child (filter_lists_dir, tmp_basename);
}

static GFile *
user_file_for_user (MctFilterUpdater *self,
                    const UserData   *user)
{
  g_autoptr(GFile) filter_lists_dir = get_filter_lists_dir (self);
  g_assert (user->username != NULL);
  return g_file_get_child (filter_lists_dir, user->username);
}

static gboolean
add_hostname_to_cdbm (const char      *hostname,
                      size_t           hostname_len,
                      struct cdb_make *cdbm,
                      char             prefix_char)
{
  char prefixed_hostname[HOSTNAME_MAX] = { '\0', };
  size_t prefixed_hostname_len;

  if (!mct_web_filter_validate_domain_name_len (hostname, hostname_len) ||
      hostname_len >= sizeof (prefixed_hostname) - 1)
    return FALSE;

  /* We require all entries in filter lists to be valid domain names. Ideally
   * we’d also require them all to be valid hostnames (since they’re only ever
   * going to be requested through a web browser). However, some filter lists on
   * the internet include non-hostname domain names, so we have to accept them. */
  if (!mct_web_filter_validate_hostname_len (hostname, hostname_len))
    g_debug ("Domain name ‘%.*s’ in filter list is not a valid hostname",
             (int) hostname_len, hostname);

  if (prefix_char != '\0')
    {
      prefixed_hostname[0] = prefix_char;
      strncpy (prefixed_hostname + 1, hostname, hostname_len);
      prefixed_hostname_len = hostname_len + 1;
    }
  else
    {
      strncpy (prefixed_hostname, hostname, hostname_len);
      prefixed_hostname_len = hostname_len;
    }

  /* Negative return values indicate an error. Positive return values indicate
   * the record already existed. Success is zero. */
  if (cdb_make_put (cdbm, prefixed_hostname, prefixed_hostname_len, NULL, 0, CDB_PUT_INSERT) < 0)
    return FALSE;

  return TRUE;
}

typedef struct
{
  const char *filter_uri;
  struct cdb_make *cdbm;
  char prefix_char;
} AddFilterListsData;

static gboolean
parsed_hostname_cb (const char  *hostname,
                    size_t       hostname_len,
                    void        *user_data,
                    GError     **error)
{
  AddFilterListsData *data = user_data;

  if (!add_hostname_to_cdbm (hostname, hostname_len, data->cdbm, data->prefix_char))
    {
      g_set_error (error, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_INVALID_FILTER_FORMAT,
                   _("Invalid hostname ‘%.*s’ in filter list from ‘%s’"),
                   (int) hostname_len, hostname, data->filter_uri);
      return FALSE;
    }

  return TRUE;
}

static gboolean
add_filter_lists_to_cdbm (MctFilterUpdater    *self,
                          GHashTable          *filter_lists,
                          const char * const  *custom_filter_list,
                          size_t               custom_filter_list_len,
                          struct cdb_make     *cdbm,
                          char                 prefix_char,
                          GError             **error)
{
  GHashTableIter iter;
  void *value;
  g_autoptr(GError) local_error = NULL;

  g_hash_table_iter_init (&iter, filter_lists);
  while (g_hash_table_iter_next (&iter, NULL, &value))
    {
      g_autoptr(GFile) cache_file = NULL;
      g_autoptr(GMappedFile) mapped_cache_file = NULL;
      const char *filter_uri = value;
      const char *filter_contents;
      size_t filter_contents_len;
      AddFilterListsData data = {
        .filter_uri = filter_uri,
        .cdbm = cdbm,
        .prefix_char = prefix_char,
      };

      cache_file = cache_file_for_uri (self, filter_uri);
      g_debug ("Adding filter rules from cached file %s%s",
               g_file_peek_path (cache_file),
               (prefix_char != '\0') ? " (with prefix)" : "");

      mapped_cache_file = g_mapped_file_new (g_file_peek_path (cache_file), FALSE, &local_error);
      if (mapped_cache_file == NULL)
        {
          g_set_error (error, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                       _("Error loading cache file ‘%s’: %s"), g_file_peek_path (cache_file),
                       local_error->message);
          return FALSE;
        }

      filter_contents = g_mapped_file_get_contents (mapped_cache_file);
      filter_contents_len = g_mapped_file_get_length (mapped_cache_file);

      if (!mct_filter_list_parse_from_data (filter_contents, filter_contents_len,
                                            parsed_hostname_cb, &data, error))
        return FALSE;
    }

  g_debug ("Adding %zu custom filter rules%s",
           custom_filter_list_len,
           (prefix_char != '\0') ? " (with prefix)" : "");

  for (size_t i = 0; i < custom_filter_list_len; i++)
    {
      if (!add_hostname_to_cdbm (custom_filter_list[i], strlen (custom_filter_list[i]), cdbm, prefix_char))
        {
          g_set_error (error, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_INVALID_FILTER_FORMAT,
                       _("Invalid hostname ‘%s’ in custom filter list"),
                       custom_filter_list[i]);
          return FALSE;
        }
    }

  return TRUE;
}

static void *
identity_copy (const void *src,
               void *user_data)
{
  return (void *) src;
}

static void *
str_copy (const void *str,
          void       *user_data)
{
  return g_strdup (str);
}

static void
ptr_array_extend_with_hash_table_values (GPtrArray  *array,
                                         GHashTable *hash_table,
                                         GCopyFunc   value_copy_func,
                                         void       *user_data)
{
  GHashTableIter iter;
  void *value;

  if (value_copy_func == NULL)
    value_copy_func = identity_copy;

  g_hash_table_iter_init (&iter, hash_table);

  while (g_hash_table_iter_next (&iter, NULL, &value))
    g_ptr_array_add (array, value_copy_func (value, user_data));
}

static void
ptr_array_uniqueify (GPtrArray    *array,
                     GCompareFunc  compare_func)
{
  g_ptr_array_sort_values (array, compare_func);

  for (size_t i = 1; i < array->len; i++)
    {
      const void *a = g_ptr_array_index (array, i - 1);
      const void *b = g_ptr_array_index (array, i);

      if (compare_func (a, b) == 0)
        {
          g_ptr_array_remove_index (array, i);
          i--;
        }
    }
}

/* See https://httpwg.org/specs/rfc7231.html#http.date
 * For example: Sun, 06 Nov 1994 08:49:37 GMT */
static gchar *
date_time_to_rfc7231 (GDateTime *date_time)
{
  return soup_date_time_to_string (date_time, SOUP_DATE_HTTP);
}

#define METADATA_ETAG_ATTRIBUTE "xattr::malcontent::etag"

/* Get an ETag from @file, from the GIO metadata, so it can be used to allow
 * HTTP caching when querying to update the file in future. Also return the
 * file’s last modified date (even if an ETag isn’t set) so that can be used as
 * a fallback for caching. */
static gchar *
get_file_etag (GFile         *file,
               GDateTime    **last_modified_date_out,
               GCancellable  *cancellable)
{
    g_autoptr(GFileInfo) info = NULL;
    const char *attributes;
    g_autoptr(GError) local_error = NULL;

    g_return_val_if_fail (G_IS_FILE (file), NULL);
    g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), NULL);

    if (last_modified_date_out == NULL)
      attributes = METADATA_ETAG_ATTRIBUTE;
    else
      attributes = METADATA_ETAG_ATTRIBUTE "," G_FILE_ATTRIBUTE_TIME_MODIFIED;

    info = g_file_query_info (file, attributes, G_FILE_QUERY_INFO_NONE, cancellable, &local_error);

    if (info == NULL)
      {
        g_debug ("Error getting attribute ‘%s’ for file ‘%s’: %s",
                 METADATA_ETAG_ATTRIBUTE, g_file_peek_path (file), local_error->message);

        if (last_modified_date_out != NULL)
          *last_modified_date_out = NULL;

        return NULL;
      }

    if (last_modified_date_out != NULL)
      *last_modified_date_out = g_file_info_get_modification_date_time (info);

    return g_strdup (g_file_info_get_attribute_string (info, METADATA_ETAG_ATTRIBUTE));
}

/* Store an ETag on @file, in the GIO metadata, so it can be used to allow HTTP
 * caching when querying to update the file in future. */
static gboolean
set_file_etag (GFile        *file,
               const char   *etag,
               GCancellable *cancellable)
{
  g_autoptr(GError) local_error = NULL;

  g_return_val_if_fail (G_IS_FILE (file), FALSE);
  g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), FALSE);

  if (etag == NULL || *etag == '\0')
    {
      if (!g_file_set_attribute (file, METADATA_ETAG_ATTRIBUTE, G_FILE_ATTRIBUTE_TYPE_INVALID,
                                 NULL, G_FILE_QUERY_INFO_NONE, cancellable, &local_error))
        {
          g_debug ("Error clearing attribute ‘%s’ on file ‘%s’: %s",
                   METADATA_ETAG_ATTRIBUTE, g_file_peek_path (file), local_error->message);
          return FALSE;
        }

      return TRUE;
    }

  if (!g_file_set_attribute_string (file, METADATA_ETAG_ATTRIBUTE, etag, G_FILE_QUERY_INFO_NONE, cancellable, &local_error))
    {
      g_debug ("Error setting attribute ‘%s’ to ‘%s’ on file ‘%s’: %s",
               METADATA_ETAG_ATTRIBUTE, etag, g_file_peek_path (file), local_error->message);
      return FALSE;
    }

  return TRUE;
}

static void update_filters_calculate_users_cb (GObject      *object,
                                               GAsyncResult *result,
                                               void         *user_data);

/**
 * mct_filter_updater_update_filters_async:
 * @self: a filter updater
 * @filter_uid: UID to update the filters for, or zero to update for all users
 * @cancellable: a cancellable, or `NULL`
 * @callback: callback for the asynchronous function
 * @user_data: data to pass to @callback
 *
 * Asynchronously updates the compiled web filters for one or more users.
 *
 * If @filter_uid is non-zero, the filters for that user will be updated. If
 * @filter_uid is zero, the filters for all users will be updated.
 *
 * See the documentation for [class@Malcontent.FilterUpdater] for details on how
 * filters are updated.
 *
 * If an update is already in progress,
 * [error@Malcontent.FilterUpdaterError.BUSY] will be raised. All other errors
 * from [error@Malcontent.FilterUpdaterError] are also possible.
 */
void
mct_filter_updater_update_filters_async (MctFilterUpdater    *self,
                                         uid_t                filter_uid,
                                         GCancellable        *cancellable,
                                         GAsyncReadyCallback  callback,
                                         void                *user_data)
{
  g_autoptr(GTask) task = NULL;
  g_autoptr(UpdateFiltersData) data_owned = NULL;
  UpdateFiltersData *data;

  g_return_if_fail (MCT_IS_FILTER_UPDATER (self));
  g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));

  task = g_task_new (self, cancellable, callback, user_data);
  g_task_set_source_tag (task, mct_filter_updater_update_filters_async);

  data = data_owned = g_new0 (UpdateFiltersData, 1);
  data->filter_uid = filter_uid;
  data->filter_lists_dir_fd = -1;
  g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) update_filters_data_free);

  if (self->update_in_progress)
    {
      g_task_return_new_error_literal (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_BUSY,
                                       _("Web filter update already in progress"));
      return;
    }

  /* Prevent duplicate updates racing within this class, and also take a file
   * lock just in case another process is running. */
  self->update_in_progress = TRUE;
  g_object_weak_ref (G_OBJECT (task), update_filters_task_weak_notify_cb, self);

  /* Open the user filter directory. */
  data->filter_lists_dir = get_filter_lists_dir (self);

  while ((data->filter_lists_dir_fd = open (g_file_peek_path (data->filter_lists_dir), O_DIRECTORY | O_CLOEXEC)) < 0)
    {
      int errsv = errno;

      if (errsv == EINTR)
        {
          continue;
        }
      else if (errsv == ENOENT)
        {
          if (mkdir (g_file_peek_path (data->filter_lists_dir), 0755) == 0)
            {
              continue;
            }
          else
            {
              int mkdir_errsv = errno;
              g_task_return_new_error (task,
                                       MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                       _("Error creating user filter directory: %s"),
                                       g_strerror (mkdir_errsv));
              return;
            }
        }
      else
        {
          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error opening user filter directory: %s"),
                                   g_strerror (errsv));
          return;
        }
    }

  /* And take a lock on it. This will be implicitly dropped when we close the FD. */
  while (flock (data->filter_lists_dir_fd, LOCK_EX | LOCK_NB) < 0)
    {
      int errsv = errno;

      if (errsv == EINTR)
        {
          continue;
        }
      else if (errsv == EWOULDBLOCK)
        {
          g_task_return_new_error_literal (task,
                                           MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_BUSY,
                                           _("Web filter update already in progress"));
          return;
        }
      else
        {
          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error opening user filter directory: %s"),
                                   g_strerror (errsv));
          return;
        }
    }

  calculate_users_async (self, filter_uid, cancellable,
                         update_filters_calculate_users_cb, g_steal_pointer (&task));
}

static void
update_filters_calculate_users_cb (GObject      *object,
                                   GAsyncResult *result,
                                   void         *user_data)
{
  MctFilterUpdater *self = MCT_FILTER_UPDATER (object);
  g_autoptr(GTask) task = G_TASK (g_steal_pointer (&user_data));
  GCancellable *cancellable = g_task_get_cancellable (task);
  UpdateFiltersData *data = g_task_get_task_data (task);
  g_autoptr(SoupSession) session = NULL;
  g_autoptr(GError) local_error = NULL;

  data->user_datas = calculate_users_finish (self, result, &local_error);
  if (data->user_datas == NULL)
    {
      g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_QUERYING_POLICY,
                               _("Error loading user’s web filtering policy: %s"), local_error->message);
      return;
    }
  else if (data->user_datas->len == 0 && data->filter_uid != 0)
    {
      g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_DISABLED,
                               _("Web filtering is disabled for user %u"), data->filter_uid);
      return;
    }

  /* Load the configurations for all the users from AccountsService and build a
   * list of all the filter files to download. */
  data->filter_uris = g_ptr_array_new_with_free_func (g_free);

  for (size_t i = 0; i < data->user_datas->len; i++)
    {
      UserData *user = &g_array_index (data->user_datas, UserData, i);
      g_autoptr(MctWebFilter) web_filter_owned = NULL;
      MctWebFilter *web_filter;
      GHashTable *block_lists, *allow_lists;

      g_debug ("Loading web filters for user %u", user->uid);

      /* FIXME Make this all async */
      web_filter = web_filter_owned = mct_manager_get_web_filter (self->policy_manager, user->uid,
                                                                  MCT_MANAGER_GET_VALUE_FLAGS_NONE,
                                                                  cancellable, &local_error);
      if (local_error != NULL &&
          g_error_matches (local_error, MCT_MANAGER_ERROR, MCT_MANAGER_ERROR_DISABLED))
        {
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_DISABLED,
                                   _("Web filtering is disabled for user %u"), user->uid);
          return;
        }
      else if (local_error != NULL)
        {
          /* In particular, we could hit this path for:
           *  - MCT_MANAGER_ERROR_INVALID_DATA
           *  - MCT_MANAGER_ERROR_INVALID_USER
           *  - MCT_MANAGER_ERROR_PERMISSION_DENIED
           * as well as misc `GDBusError` errors. */
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_QUERYING_POLICY,
                                   _("Error loading user’s web filtering policy: %s"),
                                   local_error->message);
          return;
        }

      user->web_filter = g_steal_pointer (&web_filter_owned);

      /* List the filter files to download. */
      block_lists = mct_web_filter_get_block_lists (web_filter);
      ptr_array_extend_with_hash_table_values (data->filter_uris, block_lists, str_copy, NULL);

      allow_lists = mct_web_filter_get_allow_lists (web_filter);
      ptr_array_extend_with_hash_table_values (data->filter_uris, allow_lists, str_copy, NULL);
    }

  /* Delete expired old filters from cache, if we’re updating the compiled
   * filters for all users. If only updating one user, don’t delete old filters
   * in case they’re cached for use by other users. */
  if (data->filter_uid == 0)
    {
      g_autoptr(GFileEnumerator) enumerator = NULL;
      g_autoptr(GHashTable) wanted_cache_files = NULL;

      wanted_cache_files = g_hash_table_new_full (g_file_hash, (GEqualFunc) g_file_equal, g_object_unref, NULL);
      for (size_t i = 0; i < data->filter_uris->len; i++)
        g_hash_table_add (wanted_cache_files, cache_file_for_uri (self, data->filter_uris->pdata[i]));

      enumerator = g_file_enumerate_children (self->cache_directory,
                                              G_FILE_ATTRIBUTE_STANDARD_NAME "," G_FILE_ATTRIBUTE_STANDARD_TYPE,
                                              G_FILE_QUERY_INFO_NONE,
                                              cancellable,
                                              &local_error);
      if (enumerator == NULL &&
          !g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
        {
          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error clearing old items from download cache: %s"),
                                   local_error->message);
          return;
        }

      g_clear_error (&local_error);

      while (TRUE)
        {
          GFileInfo *info;
          GFile *child;

          if (!g_file_enumerator_iterate (enumerator, &info, &child, cancellable, &local_error))
            {
              g_task_return_new_error (task,
                                       MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                       _("Error clearing old items from download cache: %s"),
                                       local_error->message);
              return;
            }

          if (info == NULL)
            break;

          /* Ignore non-files. */
          if (g_file_info_get_file_type (info) != G_FILE_TYPE_REGULAR)
            continue;

          /* Delete the cached file if it’s not listed in data->filter_uris.
           * Ignore failure because it’s only a cache file. */
          if (!g_hash_table_contains (wanted_cache_files, child))
            {
              g_debug ("Deleting old cache file for %s which is no longer in merged filter list",
                       g_file_peek_path (child));
              g_file_delete (child, cancellable, NULL);
            }
        }
    }

  /* Download the files. */
  ptr_array_uniqueify (data->filter_uris, (GCompareFunc) g_strcmp0);

  session = soup_session_new ();

  for (size_t i = 0; i < data->filter_uris->len; i++)
    {
      const char *uri = data->filter_uris->pdata[i];
      g_autoptr(SoupMessage) message = NULL;
      g_autoptr(GInputStream) input_stream = NULL;
      unsigned int status_code;
      g_autoptr(GFileOutputStream) output_stream = NULL;
      g_autoptr(GFile) cache_file = NULL;
      g_autofree char *last_etag = NULL;
      g_autoptr(GDateTime) last_modified_date = NULL;
      const char *new_etag;
      g_autofree char *last_modified_date_str = NULL;

      /* Require HTTPS, since we’re not in the 90s any more.
       * This is stricter than implementing HSTS. */
      if (g_strcmp0 (g_uri_peek_scheme (uri), "https") != 0)
        {
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_INVALID_FILTER_FORMAT,
                                   _("Invalid filter list ‘%s’: %s"),
                                   uri, _("Filter lists must be provided via HTTPS"));
          return;
        }

      /* Create a HTTP message. */
      message = soup_message_new (SOUP_METHOD_GET, uri);
      if (message == NULL)
        {
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_INVALID_FILTER_FORMAT,
                                   _("Invalid filter list ‘%s’: %s"),
                                   uri, _("Invalid URI"));
          return;
        }

      /* Caching support. Prefer ETags to modification dates, as the latter
       * have problems with rapid updates and clock drift. */
      cache_file = cache_file_for_uri (self, uri);

      last_etag = get_file_etag (cache_file, &last_modified_date, cancellable);
      if (last_etag != NULL && *last_etag == '\0')
        last_etag = NULL;

      last_modified_date_str = (last_modified_date != NULL) ? date_time_to_rfc7231 (last_modified_date) : NULL;

      if (last_etag != NULL)
        soup_message_headers_append (soup_message_get_request_headers (message), "If-None-Match", last_etag);
      else if (last_modified_date != NULL)
        soup_message_headers_append (soup_message_get_request_headers (message), "If-Modified-Since", last_modified_date_str);

      g_debug ("Downloading filter list from %s (If-None-Match: %s, If-Modified-Since: %s) into cache file %s",
               uri, last_etag, last_modified_date_str, g_file_peek_path (cache_file));

      input_stream = soup_session_send (session, message, cancellable, &local_error);

      status_code = soup_message_get_status (message);
      if (input_stream == NULL)
        {
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_DOWNLOADING,
                                   _("Failed to download URI ‘%s’: %s"),
                                   uri, local_error->message);
          return;
        }
      else if (status_code == SOUP_STATUS_NOT_MODIFIED)
        {
          /* If the file has not been modified from the ETag or
           * Last-Modified date we have, finish the download
           * early.
           *
           * Preserve the existing ETag. */
          g_debug ("Skipped downloading ‘%s’: %s",
                   uri, soup_status_get_phrase (status_code));
          continue;
        }
      else if (status_code != SOUP_STATUS_OK)
        {
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_DOWNLOADING,
                                   _("Failed to download URI ‘%s’: %s"),
                                   uri, soup_status_get_phrase (status_code));
          return;
        }

      output_stream = g_file_replace (cache_file,
                                      NULL /* ETag (different from the HTTP one!) */,
                                      FALSE  /* make_backup */,
                                      G_FILE_CREATE_PRIVATE,
                                      cancellable,
                                      &local_error);
      if (output_stream == NULL)
        {
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Failed to download URI ‘%s’: %s"),
                                   uri, local_error->message);
          return;
        }

      if (g_output_stream_splice (G_OUTPUT_STREAM (output_stream), input_stream,
                                  G_OUTPUT_STREAM_SPLICE_CLOSE_SOURCE |
                                  G_OUTPUT_STREAM_SPLICE_CLOSE_TARGET,
                                  cancellable, &local_error) < 0)
        {
          g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Failed to download URI ‘%s’: %s"),
                                   uri, local_error->message);
          return;
        }

      /* Store the new ETag for later use. */
      new_etag = soup_message_headers_get_one (soup_message_get_response_headers (message), "ETag");
      if (new_etag != NULL && *new_etag == '\0')
        new_etag = NULL;

      set_file_etag (cache_file, new_etag, cancellable);
    }

  /* For each user, compile their filters into a single big filter. This also
   * needs to pay attention to their other `MctWebFilter` options, such as
   * custom filters and whether to block by default. */
  for (size_t i = 0; i < data->user_datas->len; i++)
    {
      const UserData *user = &g_array_index (data->user_datas, UserData, i);
      MctWebFilter *web_filter = user->web_filter;
      g_autoptr(GFile) tmpfile = NULL;
      g_autofree char *tmpfile_basename = NULL;
      g_autoptr(GFile) user_file = NULL;
      g_autofree char *user_file_basename = NULL;
      struct cdb_make cdbm;
      g_autofd int tmpfile_fd = -1;
      GHashTable *allow_lists;
      const char * const *custom_allow_list;
      size_t custom_allow_list_len;

      user_file = user_file_for_user (self, user);

      if (mct_web_filter_get_filter_type (web_filter) == MCT_WEB_FILTER_TYPE_NONE)
        {
          /* Delete the old compiled filter, if it exists. */
          g_debug ("Deleting compiled filter file %s for user %u as they have web filtering disabled",
                   g_file_peek_path (user_file), user->uid);

          g_file_delete (user_file, cancellable, &local_error);
          if (local_error != NULL &&
              !g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
            {
              g_task_return_new_error (task, MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                       _("Error deleting old user web filter ‘%s’: %s"),
                                       g_file_peek_path (user_file), local_error->message);
              return;
            }

          g_clear_error (&local_error);

          continue;
        }

      /* Open a temporary file to contain the compiled filter for this user. */
      /* FIXME: Would be better for this to use openat() on filter_lists_dir_fd,
       * but I can’t get that to work */
      while ((tmpfile_fd = open (g_file_peek_path (data->filter_lists_dir), O_RDWR | O_TMPFILE | O_CLOEXEC, 0600)) < 0)
        {
          int errsv = errno;

          if (errsv == EINTR)
            continue;

          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error opening user web filter temporary file: %s"),
                                   g_strerror (errsv));
          return;
        }

      g_debug ("Building tinycdb database for user %u in temporary file", user->uid);

      cdb_make_start (&cdbm, tmpfile_fd);

      /* If blocking everything by default, add a special key to indicate this,
       * and don’t bother adding all the filter list files. */
      if (mct_web_filter_get_filter_type (web_filter) == MCT_WEB_FILTER_TYPE_ALLOWLIST)
        {
          g_debug ("Adding wildcard filter rule");
          if (cdb_make_put (&cdbm, "*", strlen ("*"), NULL, 0, CDB_PUT_REPLACE) != 0)
            {
              int errsv = errno;
              g_task_return_new_error (task,
                                       MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                       _("Error adding to user filter: %s"), g_strerror (errsv));
              return;
            }
        }
      else
        {
          GHashTable *block_lists;
          const char * const *custom_block_list;
          size_t custom_block_list_len;

          block_lists = mct_web_filter_get_block_lists (web_filter);
          custom_block_list = mct_web_filter_get_custom_block_list (web_filter, &custom_block_list_len);

          if (!add_filter_lists_to_cdbm (self, block_lists,
                                         custom_block_list, custom_block_list_len,
                                         &cdbm, 0, &local_error))
            {
              g_assert (local_error->domain == MCT_FILTER_UPDATER_ERROR);
              g_task_return_error (task, g_steal_pointer (&local_error));
              return;
            }
        }

      /* Add the allow lists. */
      allow_lists = mct_web_filter_get_allow_lists (web_filter);
      custom_allow_list = mct_web_filter_get_custom_allow_list (web_filter, &custom_allow_list_len);

      if (!add_filter_lists_to_cdbm (self, allow_lists,
                                     custom_allow_list, custom_allow_list_len,
                                     &cdbm, '~', &local_error))
        {
          g_assert (local_error->domain == MCT_FILTER_UPDATER_ERROR);
          g_task_return_error (task, g_steal_pointer (&local_error));
          return;
        }

      /* Finally, add the redirections to force safe search to be enabled. */
      if (mct_web_filter_get_force_safe_search (web_filter))
        {
          const struct
            {
              const char *hostname;
              const char *replacement;
            }
          safe_search_substitutions[] =
            {
              { "duckduckgo.com", "safe.duckduckgo.com" },
              { "www.duckduckgo.com", "safe.duckduckgo.com" },
              { "start.duckduckgo.com", "safe.duckduckgo.com" },

              { "www.bing.com", "strict.bing.com" },

              { "www.google.ad", "forcesafesearch.google.com" },
              { "www.google.ae", "forcesafesearch.google.com" },
              { "www.google.al", "forcesafesearch.google.com" },
              { "www.google.am", "forcesafesearch.google.com" },
              { "www.google.as", "forcesafesearch.google.com" },
              { "www.google.at", "forcesafesearch.google.com" },
              { "www.google.az", "forcesafesearch.google.com" },
              { "www.google.ba", "forcesafesearch.google.com" },
              { "www.google.be", "forcesafesearch.google.com" },
              { "www.google.bf", "forcesafesearch.google.com" },
              { "www.google.bg", "forcesafesearch.google.com" },
              { "www.google.bi", "forcesafesearch.google.com" },
              { "www.google.bj", "forcesafesearch.google.com" },
              { "www.google.bs", "forcesafesearch.google.com" },
              { "www.google.bt", "forcesafesearch.google.com" },
              { "www.google.by", "forcesafesearch.google.com" },
              { "www.google.ca", "forcesafesearch.google.com" },
              { "www.google.cd", "forcesafesearch.google.com" },
              { "www.google.cf", "forcesafesearch.google.com" },
              { "www.google.cg", "forcesafesearch.google.com" },
              { "www.google.ch", "forcesafesearch.google.com" },
              { "www.google.ci", "forcesafesearch.google.com" },
              { "www.google.cl", "forcesafesearch.google.com" },
              { "www.google.cm", "forcesafesearch.google.com" },
              { "www.google.cn", "forcesafesearch.google.com" },
              { "www.google.co.ao", "forcesafesearch.google.com" },
              { "www.google.co.bw", "forcesafesearch.google.com" },
              { "www.google.co.ck", "forcesafesearch.google.com" },
              { "www.google.co.cr", "forcesafesearch.google.com" },
              { "www.google.co.id", "forcesafesearch.google.com" },
              { "www.google.co.il", "forcesafesearch.google.com" },
              { "www.google.co.in", "forcesafesearch.google.com" },
              { "www.google.co.jp", "forcesafesearch.google.com" },
              { "www.google.co.ke", "forcesafesearch.google.com" },
              { "www.google.co.kr", "forcesafesearch.google.com" },
              { "www.google.co.ls", "forcesafesearch.google.com" },
              { "www.google.co.ma", "forcesafesearch.google.com" },
              { "www.google.co.mz", "forcesafesearch.google.com" },
              { "www.google.co.nz", "forcesafesearch.google.com" },
              { "www.google.co.th", "forcesafesearch.google.com" },
              { "www.google.co.tz", "forcesafesearch.google.com" },
              { "www.google.co.ug", "forcesafesearch.google.com" },
              { "www.google.co.uk", "forcesafesearch.google.com" },
              { "www.google.co.uz", "forcesafesearch.google.com" },
              { "www.google.co.ve", "forcesafesearch.google.com" },
              { "www.google.co.vi", "forcesafesearch.google.com" },
              { "www.google.com", "forcesafesearch.google.com" },
              { "www.google.com.af", "forcesafesearch.google.com" },
              { "www.google.com.ag", "forcesafesearch.google.com" },
              { "www.google.com.ai", "forcesafesearch.google.com" },
              { "www.google.com.ar", "forcesafesearch.google.com" },
              { "www.google.com.au", "forcesafesearch.google.com" },
              { "www.google.com.bd", "forcesafesearch.google.com" },
              { "www.google.com.bh", "forcesafesearch.google.com" },
              { "www.google.com.bn", "forcesafesearch.google.com" },
              { "www.google.com.bo", "forcesafesearch.google.com" },
              { "www.google.com.br", "forcesafesearch.google.com" },
              { "www.google.com.bz", "forcesafesearch.google.com" },
              { "www.google.com.co", "forcesafesearch.google.com" },
              { "www.google.com.cu", "forcesafesearch.google.com" },
              { "www.google.com.cy", "forcesafesearch.google.com" },
              { "www.google.com.do", "forcesafesearch.google.com" },
              { "www.google.com.ec", "forcesafesearch.google.com" },
              { "www.google.com.eg", "forcesafesearch.google.com" },
              { "www.google.com.et", "forcesafesearch.google.com" },
              { "www.google.com.fj", "forcesafesearch.google.com" },
              { "www.google.com.gh", "forcesafesearch.google.com" },
              { "www.google.com.gi", "forcesafesearch.google.com" },
              { "www.google.com.gt", "forcesafesearch.google.com" },
              { "www.google.com.hk", "forcesafesearch.google.com" },
              { "www.google.com.jm", "forcesafesearch.google.com" },
              { "www.google.com.kh", "forcesafesearch.google.com" },
              { "www.google.com.kw", "forcesafesearch.google.com" },
              { "www.google.com.lb", "forcesafesearch.google.com" },
              { "www.google.com.ly", "forcesafesearch.google.com" },
              { "www.google.com.mm", "forcesafesearch.google.com" },
              { "www.google.com.mt", "forcesafesearch.google.com" },
              { "www.google.com.mx", "forcesafesearch.google.com" },
              { "www.google.com.my", "forcesafesearch.google.com" },
              { "www.google.com.na", "forcesafesearch.google.com" },
              { "www.google.com.nf", "forcesafesearch.google.com" },
              { "www.google.com.ng", "forcesafesearch.google.com" },
              { "www.google.com.ni", "forcesafesearch.google.com" },
              { "www.google.com.np", "forcesafesearch.google.com" },
              { "www.google.com.om", "forcesafesearch.google.com" },
              { "www.google.com.pa", "forcesafesearch.google.com" },
              { "www.google.com.pe", "forcesafesearch.google.com" },
              { "www.google.com.pg", "forcesafesearch.google.com" },
              { "www.google.com.ph", "forcesafesearch.google.com" },
              { "www.google.com.pk", "forcesafesearch.google.com" },
              { "www.google.com.pr", "forcesafesearch.google.com" },
              { "www.google.com.py", "forcesafesearch.google.com" },
              { "www.google.com.qa", "forcesafesearch.google.com" },
              { "www.google.com.sa", "forcesafesearch.google.com" },
              { "www.google.com.sb", "forcesafesearch.google.com" },
              { "www.google.com.sg", "forcesafesearch.google.com" },
              { "www.google.com.sl", "forcesafesearch.google.com" },
              { "www.google.com.sv", "forcesafesearch.google.com" },
              { "www.google.com.tj", "forcesafesearch.google.com" },
              { "www.google.com.tr", "forcesafesearch.google.com" },
              { "www.google.com.tw", "forcesafesearch.google.com" },
              { "www.google.com.ua", "forcesafesearch.google.com" },
              { "www.google.com.uy", "forcesafesearch.google.com" },
              { "www.google.com.vc", "forcesafesearch.google.com" },
              { "www.google.com.vn", "forcesafesearch.google.com" },
              { "www.google.cv", "forcesafesearch.google.com" },
              { "www.google.cz", "forcesafesearch.google.com" },
              { "www.google.de", "forcesafesearch.google.com" },
              { "www.google.dj", "forcesafesearch.google.com" },
              { "www.google.dk", "forcesafesearch.google.com" },
              { "www.google.dm", "forcesafesearch.google.com" },
              { "www.google.dz", "forcesafesearch.google.com" },
              { "www.google.ee", "forcesafesearch.google.com" },
              { "www.google.es", "forcesafesearch.google.com" },
              { "www.google.fi", "forcesafesearch.google.com" },
              { "www.google.fm", "forcesafesearch.google.com" },
              { "www.google.fr", "forcesafesearch.google.com" },
              { "www.google.ga", "forcesafesearch.google.com" },
              { "www.google.ge", "forcesafesearch.google.com" },
              { "www.google.gg", "forcesafesearch.google.com" },
              { "www.google.gl", "forcesafesearch.google.com" },
              { "www.google.gm", "forcesafesearch.google.com" },
              { "www.google.gp", "forcesafesearch.google.com" },
              { "www.google.gr", "forcesafesearch.google.com" },
              { "www.google.gy", "forcesafesearch.google.com" },
              { "www.google.hn", "forcesafesearch.google.com" },
              { "www.google.hr", "forcesafesearch.google.com" },
              { "www.google.ht", "forcesafesearch.google.com" },
              { "www.google.hu", "forcesafesearch.google.com" },
              { "www.google.ie", "forcesafesearch.google.com" },
              { "www.google.im", "forcesafesearch.google.com" },
              { "www.google.iq", "forcesafesearch.google.com" },
              { "www.google.is", "forcesafesearch.google.com" },
              { "www.google.it", "forcesafesearch.google.com" },
              { "www.google.je", "forcesafesearch.google.com" },
              { "www.google.jo", "forcesafesearch.google.com" },
              { "www.google.kg", "forcesafesearch.google.com" },
              { "www.google.ki", "forcesafesearch.google.com" },
              { "www.google.kz", "forcesafesearch.google.com" },
              { "www.google.la", "forcesafesearch.google.com" },
              { "www.google.li", "forcesafesearch.google.com" },
              { "www.google.lk", "forcesafesearch.google.com" },
              { "www.google.lt", "forcesafesearch.google.com" },
              { "www.google.lu", "forcesafesearch.google.com" },
              { "www.google.lv", "forcesafesearch.google.com" },
              { "www.google.md", "forcesafesearch.google.com" },
              { "www.google.me", "forcesafesearch.google.com" },
              { "www.google.mg", "forcesafesearch.google.com" },
              { "www.google.mk", "forcesafesearch.google.com" },
              { "www.google.ml", "forcesafesearch.google.com" },
              { "www.google.mn", "forcesafesearch.google.com" },
              { "www.google.ms", "forcesafesearch.google.com" },
              { "www.google.mu", "forcesafesearch.google.com" },
              { "www.google.mv", "forcesafesearch.google.com" },
              { "www.google.mw", "forcesafesearch.google.com" },
              { "www.google.ne", "forcesafesearch.google.com" },
              { "www.google.nl", "forcesafesearch.google.com" },
              { "www.google.no", "forcesafesearch.google.com" },
              { "www.google.nr", "forcesafesearch.google.com" },
              { "www.google.nu", "forcesafesearch.google.com" },
              { "www.google.pl", "forcesafesearch.google.com" },
              { "www.google.pn", "forcesafesearch.google.com" },
              { "www.google.ps", "forcesafesearch.google.com" },
              { "www.google.pt", "forcesafesearch.google.com" },
              { "www.google.ro", "forcesafesearch.google.com" },
              { "www.google.rs", "forcesafesearch.google.com" },
              { "www.google.ru", "forcesafesearch.google.com" },
              { "www.google.rw", "forcesafesearch.google.com" },
              { "www.google.sc", "forcesafesearch.google.com" },
              { "www.google.se", "forcesafesearch.google.com" },
              { "www.google.sh", "forcesafesearch.google.com" },
              { "www.google.si", "forcesafesearch.google.com" },
              { "www.google.sk", "forcesafesearch.google.com" },
              { "www.google.sm", "forcesafesearch.google.com" },
              { "www.google.sn", "forcesafesearch.google.com" },
              { "www.google.so", "forcesafesearch.google.com" },
              { "www.google.sr", "forcesafesearch.google.com" },
              { "www.google.st", "forcesafesearch.google.com" },
              { "www.google.td", "forcesafesearch.google.com" },
              { "www.google.tg", "forcesafesearch.google.com" },
              { "www.google.tk", "forcesafesearch.google.com" },
              { "www.google.tl", "forcesafesearch.google.com" },
              { "www.google.tm", "forcesafesearch.google.com" },
              { "www.google.tn", "forcesafesearch.google.com" },
              { "www.google.to", "forcesafesearch.google.com" },
              { "www.google.tt", "forcesafesearch.google.com" },
              { "www.google.vg", "forcesafesearch.google.com" },
              { "www.google.vu", "forcesafesearch.google.com" },
              { "www.google.ws", "forcesafesearch.google.com" },

              { "m.youtube.com", "restrict.youtube.com" },
              { "www.youtube-nocookie.com", "restrict.youtube.com" },
              { "www.youtube.com", "restrict.youtube.com" },
              { "youtube.googleapis.com", "restrict.youtube.com" },
              { "youtubei.googleapis.com", "restrict.youtube.com" },

              { "pixabay.com", "safesearch.pixabay.com" },
            };

          g_debug ("Adding safe search substitutions");

          /* FIXME: Perhaps also block search engines which don’t support safe
           * search: https://github.com/hagezi/dns-blocklists?tab=readme-ov-file#safesearch */
          for (size_t j = 0; j < G_N_ELEMENTS (safe_search_substitutions); j++)
            {
              if (cdb_make_put (&cdbm, safe_search_substitutions[j].hostname, strlen (safe_search_substitutions[j].hostname),
                                safe_search_substitutions[j].replacement, strlen (safe_search_substitutions[j].replacement),
                                CDB_PUT_REPLACE) != 0)
                {
                  int errsv = errno;
                  g_task_return_new_error (task,
                                           MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                           _("Error adding to user filter: %s"), g_strerror (errsv));
                  return;
                }
            }
        }

      /* Write the indexes. */
      if (cdb_make_finish (&cdbm) != 0)
        {
          int errsv = errno;
          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error adding to user filter: %s"), g_strerror (errsv));
          return;
        }

      /* Atomically replace the compiled filter file with the new version. This
       * will cause the libnss module to reload it in user processes.
       * It would be nice if we could do an atomic rename with the linkat()
       * call, but that’s not currently possible, so this has to be two steps.
       * At least it means we can set file permissions in the middle. */
      tmpfile = tmp_file_for_user (self, user);
      tmpfile_basename = g_file_get_basename (tmpfile);
      user_file_basename = g_file_get_basename (user_file);

      g_debug ("Committing tinycdb database for user %u to file %s",
               user->uid, g_file_peek_path (user_file));

      while (linkat (tmpfile_fd, "", data->filter_lists_dir_fd, tmpfile_basename, AT_EMPTY_PATH) < 0)
        {
          int errsv = errno;

          if (errsv == EINTR)
            continue;

          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error committing user filter: %s"),
                                   g_strerror (errsv));
          return;
        }

      while (fchmod (tmpfile_fd, 0644) < 0)
        {
          int errsv = errno;

          if (errsv == EINTR)
            continue;

          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error committing user filter: %s"),
                                   g_strerror (errsv));
          return;
        }

      while (renameat (data->filter_lists_dir_fd, tmpfile_basename, data->filter_lists_dir_fd, user_file_basename) < 0)
        {
          int errsv = errno;

          if (errsv == EINTR)
            continue;

          /* Clean up. */
          unlinkat (data->filter_lists_dir_fd, tmpfile_basename, 0  /* flags */);

          g_task_return_new_error (task,
                                   MCT_FILTER_UPDATER_ERROR, MCT_FILTER_UPDATER_ERROR_FILE_SYSTEM,
                                   _("Error committing user filter: %s"),
                                   g_strerror (errsv));
          return;
        }
    }

  /* Success! */
  g_task_return_boolean (task, TRUE);
}

/**
 * mct_filter_updater_update_filters_finish:
 * @self: a filter updater
 * @result: result of the asynchronous operation
 * @error: return location for a [type@GLib.Error], or `NULL`
 *
 * Finish an asynchronous operation to update the filters.
 *
 * Returns: true on success, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_filter_updater_update_filters_finish (MctFilterUpdater  *self,
                                          GAsyncResult      *result,
                                          GError           **error)
{
  g_return_val_if_fail (MCT_IS_FILTER_UPDATER (self), FALSE);
  g_return_val_if_fail (g_task_is_valid (result, self), FALSE);
  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);

  return g_task_propagate_boolean (G_TASK (result), error);
}

/**
 * mct_filter_updater_new:
 * @policy_manager: (transfer none): parental controls policy manager
 * @user_manager: (transfer none): user manager
 * @state_directory: (transfer none): directory to store state in
 * @cache_directory: (transfer none): directory to store cached data in
 *
 * Create a new [class@Malcontent.FilterUpdater].
 *
 * Returns: (transfer full): a new [class@Malcontent.FilterUpdater]
 * Since: 0.14.0
 */
MctFilterUpdater *
mct_filter_updater_new (MctManager     *policy_manager,
                        MctUserManager *user_manager,
                        GFile          *state_directory,
                        GFile          *cache_directory)
{
  g_return_val_if_fail (MCT_IS_MANAGER (policy_manager), NULL);
  g_return_val_if_fail (MCT_IS_USER_MANAGER (user_manager), NULL);
  g_return_val_if_fail (G_IS_FILE (state_directory), NULL);
  g_return_val_if_fail (G_IS_FILE (cache_directory), NULL);

  return g_object_new (MCT_TYPE_FILTER_UPDATER,
                       "policy-manager", policy_manager,
                       "user-manager", user_manager,
                       "state-directory", state_directory,
                       "cache-directory", cache_directory,
                       NULL);
}

