#!/bin/bash

## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

## How to call, example:
## source_config /etc/systemcheck.d /usr/local/etc/systemcheck.d

## source_config sources trusted Bash configuration fragments (*.conf).
##
## Security model:
## - This function is intentionally designed to execute shell code from the
##   configuration files by using Bash `source`.
## - Therefore, it MUST only be used with trusted configuration directories on a
##   trusted filesystem.
## - This function is not a parser for untrusted data.
## - For per-user configuration directories, the invoking process's current
##   effective group is assumed to be trusted for that configuration context.
##   In the common case, this is expected to be the user's private group, but
##   this function intentionally follows the current effective group as reported
##   by `id --group --name`.
## - Symlinked configuration directories and symlinked configuration files are
##   allowed by this security model.
## - Ownership, group, and permission checks apply to the resolved target as
##   reported by `stat`.
## - Parent directories along the provided path are checked as defense in depth.
## - For symlinked configuration directories, the resolved target directory
##   hierarchy is also checked as defense in depth.
## - For symlinked configuration files, the resolved target file's parent
##   directory hierarchy is also checked as defense in depth.
## - Supported-directory checks are based on the pathname provided by the caller.
##   Additional checks on resolved symlink targets are defense in depth. They are
##   not intended to be canonical supported-directory containment checks after
##   resolving all symlinks.
## - For per-user configuration paths, expected ownership for resolved symlink
##   targets is still derived from the resolved pathname being checked.
##   Therefore, if a per-user configuration symlink resolves to a path outside
##   the literal `$HOME` pathname prefix, the resolved target hierarchy is
##   expected to be owned by `root:root` and may be rejected even if it is
##   user-owned. This stricter behavior is accepted as defense in depth.
## - The caller is trusted to pass the intended configuration directories.
## - "Trusted" in this context means:
##   * the directory hierarchy is only writable by the trusted owner or an
##     equivalently trusted authority such as root;
##   * ownership and permissions are administered as intended;
##   * the filesystem and mount context are trusted to enforce Unix ownership and
##     permission semantics correctly.
## - Parent directories are checked as defense in depth.
##   For per-user paths, directories above `$HOME` are expected to be
##   administered by root, while `$HOME` and directories below it are expected
##   to be administered by the invoking user and the invoking process's current
##   effective group.
## - This function performs pathname-based checks before sourcing files.
##   A time-of-check/time-of-use race condition remains possible in principle.
## - Within this security model, that limitation is accepted because exploiting
##   it would require write access to the trusted configuration directory or file
##   path, which would already violate the trust assumptions and imply a system
##   or account compromise.
## - ACLs and other non-mode permission mechanisms are intentionally not
##   considered. This function checks classic Unix ownership and mode bits only.
##   Users deploying such mechanisms are expected to understand and administer
##   them appropriately within this trust model.
## - Core utilities and environment used to reach them are part of the trust
##   boundary. In particular, `id`, `env`, `stat`, `readlink`, `bash`, and the
##   environment variables `PATH` and `HOME` are trusted. If an attacker could
##   control these in a relevant way, the system or account would already be
##   considered compromised under this security model.
## - This implementation intentionally relies on Bash dynamic scoping in helper
##   functions:
##   * `source_config_error` sets the caller's local `rc`;
##   * `source_config_get_expected_owner_group` reads the caller's local
##     `invoking_user` and `invoking_group` values, where `invoking_group`
##     reflects the current effective group selected by this implementation.
##   This is intentional and part of the implementation contract of these helper
##   functions. They are not designed as fully self-contained pure functions.
##
## Expected ownership / permissions:
##
## 1) System configuration directories, such as /etc/..., /run/..., or /usr/...
##    - directories and files should be owned by user root and group root
##    - directories must not be group-writable or world-writable
##    - files must not be writable by "others"
##    - group-writable files are acceptable only when the file group is root
##
## 2) Per-user configuration directories, such as ~/...
##    - directories and files should be owned by the invoking user
##    - directories must not be group-writable or world-writable
##    - files must not be writable by "others"
##    - group-writable files are acceptable only when the file group matches the
##      invoking process's current effective group
##
## Notes:
## - The ownership / permission checks are defense-in-depth and help catch
##   packaging or administration mistakes.
## - They do not change the fundamental trust requirement: sourced files are
##   executable shell code.
## - `bash -n` is used as a simple syntax check before `source`.
##   It is not a security boundary.
## - The `true "..."` pattern is intentionally used for optional debug text under
##   xtrace (`set -x`).
## - Non-existing configuration folders are allowed.
## - Existing paths that are not directories are rejected.

source_config_error() {
  printf '%s\n' "$0: ERROR: $1" >&2
  ## Relies on Bash dynamic scoping to update source_config()'s local rc.
  rc=1
}

source_config_read_stat() {
  local _path="$1"
  local -n _out_user="$2"
  local -n _out_group="$3"
  local -n _out_perms="$4"
  local _output

  ## Quote 'stat' man page:
  ## > Due to shell aliases and built-in stat functions, using an unadorned stat
  ## > interactively or in a script may get you different functionality than
  ## > that described here. Invoke it via env (i.e., env stat ...) to avoid
  ## > interference from the shell.
  ##
  ## Space-delimited parsing assumes user/group names contain no spaces.
  ## POSIX and useradd prohibit this in practice.  Alternative delimiters
  ## were considered but each has its own drawback:
  ##   NUL:     cannot be stored in bash variables, requiring process
  ##            substitution which discards the stat exit status.
  ##   newline: same practical-impossibility tier as space.
  ##   colon:   prohibited by /etc/passwd format, but not a kernel guarantee.
  ## The current approach is fail-closed: a space in a name would cause the
  ## ownership comparison to fail, not to silently accept a wrong owner.
  _output="$(env LC_ALL=C stat -L --format '%U %G %a' -- "$_path" 2>/dev/null)" || return 1
  read -r _out_user _out_group _out_perms <<< "$_output" || return 1

  return 0
}

source_config_resolve_path() {
  local _path="$1"
  local -n _out_resolved="$2"
  local _resolved

  _resolved="$(env readlink -f -- "$_path" 2>/dev/null)" || return 1
  _out_resolved="$_resolved"

  return 0
}

source_config_get_expected_owner_group() {
  local _path="$1"
  local _kind="$2"
  local -n _out_user="$3"
  local -n _out_group="$4"

  case "$_kind" in
    system)
      _out_user='root'
      _out_group='root'
      ;;
    user)
      case "$_path" in
        "$HOME"|"$HOME"/*)
          ## Relies on Bash dynamic scoping to read source_config()'s locals.
          _out_user="$invoking_user"
          _out_group="$invoking_group"
          ;;
        *)
          _out_user='root'
          _out_group='root'
          ;;
      esac
      ;;
    *)
      return 1
      ;;
  esac

  return 0
}

source_config_check_directory() {
  local directory_path="$1"
  local expected_user="$2"
  local expected_group="$3"
  local actual_user actual_group actual_perms

  if [ ! -e "$directory_path" ]; then
    ## Intentionally written this way so the message is visible under xtrace.
    true "$0: Config path '$directory_path' does not exist, skipping."
    return 0
  fi

  if [ ! -d "$directory_path" ]; then
    source_config_error "Config path '$directory_path' exists but is not a directory."
    return 1
  fi

  if ! source_config_read_stat "$directory_path" actual_user actual_group actual_perms; then
    source_config_error "Could not stat config directory '$directory_path'."
    return 1
  fi

  if [ "$actual_user" != "$expected_user" ]; then
    source_config_error "Config directory '$directory_path' has unexpected owner '$actual_user'."
    return 1
  fi

  if [ "$actual_group" != "$expected_group" ]; then
    source_config_error "Config directory '$directory_path' has unexpected group '$actual_group'."
    return 1
  fi

  case "$actual_perms" in
    *[2367]?)
      source_config_error "Config directory '$directory_path' is group-writable (mode $actual_perms)."
      return 1
      ;;
    *2|*3|*6|*7)
      source_config_error "Config directory '$directory_path' is world-writable (mode $actual_perms)."
      return 1
      ;;
  esac

  return 0
}

source_config_check_path_chain() {
  local target_path="$1"
  local config_kind="$2"
  local current_path parent_path expected_user expected_group
  local path_index
  local -a path_chain

  current_path="$target_path"

  while :; do
    path_chain+=("$current_path")

    if [ "$current_path" = '/' ]; then
      break
    fi

    parent_path="${current_path%/*}"

    if [ -z "$parent_path" ]; then
      parent_path='/'
    fi

    current_path="$parent_path"
  done

  for (( path_index=${#path_chain[@]} - 1; path_index >= 0; path_index-- )); do
    current_path="${path_chain[$path_index]}"

    if ! source_config_get_expected_owner_group \
      "$current_path" \
      "$config_kind" \
      expected_user \
      expected_group
    then
      source_config_error "Internal error while determining expected ownership for '$current_path'."
      return 1
    fi

    if ! source_config_check_directory \
      "$current_path" \
      "$expected_user" \
      "$expected_group"
    then
      return 1
    fi
  done

  return 0
}

source_config_check_directory_hierarchy() {
  local target_directory="$1"
  local config_kind="$2"
  local resolved_directory

  if ! source_config_check_path_chain "$target_directory" "$config_kind"; then
    return 1
  fi

  if ! source_config_resolve_path "$target_directory" resolved_directory; then
    source_config_error "Could not resolve config directory '$target_directory'."
    return 1
  fi

  if [ "$resolved_directory" != "$target_directory" ]; then
    if ! source_config_check_path_chain "$resolved_directory" "$config_kind"; then
      return 1
    fi
  fi

  return 0
}

source_config_check_resolved_file_parent_hierarchy() {
  local file_path="$1"
  local config_kind="$2"
  local resolved_file resolved_parent

  if ! source_config_resolve_path "$file_path" resolved_file; then
    source_config_error "Could not resolve configuration file '$file_path'."
    return 1
  fi

  resolved_parent="${resolved_file%/*}"

  if [ -z "$resolved_parent" ]; then
    resolved_parent='/'
  fi

  if ! source_config_check_path_chain "$resolved_parent" "$config_kind"; then
    return 1
  fi

  return 0
}

source_config() {
  local folder_item file_name
  local file_perms file_user file_group
  local nullglob_was_set=0
  local trusted_user trusted_group
  local invoking_user invoking_group
  local config_kind
  ## Dynamic scoping: source_config_error() updates this local variable.
  local rc=0

  invoking_user="$(id --user --name)" || {
    source_config_error "Could not determine invoking user."
    return 1
  }
  invoking_group="$(id --group --name)" || {
    source_config_error "Could not determine invoking group."
    return 1
  }

  shopt -q nullglob && nullglob_was_set=1
  shopt -s nullglob

  for folder_item in "$@"; do
    while [ "$folder_item" != '/' ] && [ "${folder_item%/}" != "$folder_item" ]; do
      folder_item="${folder_item%/}"
    done

    case "$folder_item" in
      */./*|*/../*|*/.|*/..)
        source_config_error "Config directory '$folder_item' must not contain '.' or '..' path components."
        break
        ;;
    esac

    case "$folder_item" in
      /etc/*|/run/*|/usr/*)
        trusted_user='root'
        trusted_group='root'
        config_kind='system'
        ;;
      "$HOME"/*)
        case "$HOME" in
          /*)
            ;;
          *)
            source_config_error "HOME is not set to an absolute path."
            break
            ;;
        esac
        trusted_user="$invoking_user"
        trusted_group="$invoking_group"
        config_kind='user'
        ;;
      *)
        source_config_error "Unsupported config directory '$folder_item'."
        break
        ;;
    esac

    if ! source_config_check_directory_hierarchy "$folder_item" "$config_kind"; then
      break
    fi

    for file_name in "$folder_item"/*.conf; do
      if [ ! -f "$file_name" ]; then
        source_config_error "Configuration entry '$file_name' is not a regular file."
        break 2
      fi

      if ! source_config_check_resolved_file_parent_hierarchy "$file_name" "$config_kind"; then
        break 2
      fi

      if ! source_config_read_stat "$file_name" file_user file_group file_perms; then
        source_config_error "Could not stat '$file_name'."
        break 2
      fi

      if [ "$file_user" != "$trusted_user" ]; then
        source_config_error "Configuration file '$file_name' has unexpected owner '$file_user'."
        break 2
      fi

      if [ "$file_group" != "$trusted_group" ]; then
        source_config_error "Configuration file '$file_name' has unexpected group '$file_group'."
        break 2
      fi

      case "$file_perms" in
        *2|*3|*6|*7)
          source_config_error "Configuration file '$file_name' is world-writable."
          break 2
          ;;
      esac

      ## Simple syntax check.
      if ! bash -n -- "$file_name"; then
        source_config_error "Syntax check failed: '$file_name'."
        break 2
      fi

      # shellcheck disable=SC1090
      if ! source "$file_name"; then
        source_config_error "Failed to source '$file_name'."
        break 2
      fi
    done
  done

  if [ "$nullglob_was_set" -eq 0 ]; then
    shopt -u nullglob
  fi

  return "$rc"
}
