#!/bin/bash
# shellcheck disable=SC2076

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

## https://forums.whonix.org/t/disable-suid-binaries/7706
## https://forums.whonix.org/t/re-mount-home-and-other-with-noexec-and-nosuid-among-other-useful-mount-options-for-better-security/7707

## dpkg-statoverride does not support end-of-options ("--").

## SC2076 is disabled because ShellCheck seems to think that any use of
## [[ ... =~ ... ]] is supposed to be a regex match. But [[ '...' =~ '...' ]]
## works very well for literal matching, and it is used that way extensively
## throughout this script.

set -o errexit -o nounset -o pipefail

## Constants
# shellcheck disable=SC2034
log_level=notice
store_dir="/var/lib/permission-hardener-v2"
state_file="${store_dir}/existing_mode/statoverride"
dpkg_admindir_parameter_existing_mode="--admindir ${store_dir}/existing_mode"
dpkg_admindir_parameter_new_mode="--admindir ${store_dir}/new_mode"
delimiter="#permission-hardener-delimiter#"

## Library imports
# shellcheck disable=SC1091
source /usr/libexec/helper-scripts/safe_echo.sh
# shellcheck disable=SC1091
source /usr/libexec/helper-scripts/log_run_die.sh
# shellcheck disable=SC1091
source /usr/libexec/helper-scripts/strings.bsh

## Functions
echo_wrapper_ignore() {
  if [ "${1}" = 'verbose' ]; then
    shift
    log notice "Executing: $*"
  elif [ "${1}" = 'silent' ]; then
    shift
  else
    log error "Unrecognized command '${1}'! calling function name: '${FUNCNAME[1]}'" >&2
    return
  fi
  "$@" 2>/dev/null || true
}

echo_wrapper_audit() {
  local return_code
  if [ "${1}" = 'verbose' ]; then
    shift
    log notice "Executing: $*"
  elif [ "${1}" = 'silent' ]; then
    shift
  else
    log error "Unrecognized command '${1}'! calling function name: '${FUNCNAME[1]}'" >&2
    return
  fi
  return_code=0
  "$@" ||
    {
      return_code="$?"
      exit_code=203
      log error "Command '$*' failed with exit code '${return_code}'! calling function name: '${FUNCNAME[1]}'" >&2
    }
}

## Some tools may fail on newlines and even variable assignment to array may
## fail if a variable that will be assigned to an array element contains
## characters that are used as delimiters.
block_newlines() {
  local newline_variable newline_value
  newline_variable="${1:-}"
  newline_value="${2:-}"
  ## dpkg-statoverride: error: path may not contain newlines
  if [[ "${newline_value}" != "${newline_value//$'\n'/NEWLINE}" ]]; then
    log warn "Skipping ${newline_variable} that contains newlines: '${newline_value}'" >&2
    return 1
  fi
}

output_stat() {
  local file_name stat_output stat_output_newlined hardlink_count
  declare -a arr
  file_name="${1:-}"

  if [ -z "${file_name}" ]; then
    log error "File name is empty. file_name: '${file_name}'" >&2
    return 1
  fi

  if ! block_newlines file "${file_name}"; then
    existing_mode=''
    existing_owner=''
    existing_group=''
    file_name_from_stat=''
    return 0
  fi

  if [ ! -e "${file_name}" ]; then
    log info "File does not exist. file_name: '${file_name}'" >&2
    existing_mode=''
    existing_owner=''
    existing_group=''
    file_name_from_stat=''
    return 0
  fi

  if ! stat_output="$(stat -L \
    --format="%a${delimiter}%U${delimiter}%G${delimiter}%n${delimiter}%h${delimiter}" \
    -- "${file_name}")"; then
    log error "Failed to run 'stat' on file: '${file_name}'!" >&2
    return 1
  fi

  if [ -z "$stat_output" ]; then
    log error "stat_output is empty.
File name: '${file_name}'
Stat output: '${stat_output}'
stat_output_newlined: '${stat_output_newlined}'
line: '${processed_config_line}'
" >&2
    return 1
  fi

  stat_output_newlined="$(printf '%s\n' "${stat_output//${delimiter}/$'\n'}")"

  if [ -z "${stat_output_newlined}" ]; then
    log error "stat_output_newlined is empty.
File name: '${file_name}'
Stat output: '${stat_output}'
stat_output_newlined: '${stat_output_newlined}'
line: '${processed_config_line}'
" >&2
    return 1
  fi

  readarray -t arr <<< "${stat_output_newlined}"

  if [ "${#arr[@]}" = '0' ]; then
    log error "Array length is 0.
File name: '${file_name}'
Stat output: '${stat_output}'
stat_output_newlined: '${stat_output_newlined}'
line: '${processed_config_line}'
" >&2
    return 1
  fi

  existing_mode="${arr[0]}"
  existing_owner="${arr[1]}"
  existing_group="${arr[2]}"
  file_name_from_stat="${arr[3]}"
  hardlink_count="${arr[4]}"

  if ! is_whole_number "${hardlink_count}"; then
    log error "\
hardlink_count returned by stat is not an integer.
Stat output: '${stat_output}'
stat_output_newlined: '${stat_output_newlined}'
line: '${processed_config_line}'
hardlink_count: '${hardlink_count}'
" >&2
    return 1
  fi

  if [ "$file_name" != "$file_name_from_stat" ]; then
    log error "\
File name is different from file name received from stat:
File name: '${file_name}'
File name from stat: '${file_name_from_stat}'
line: '${processed_config_line}'
" >&2
    return 1
  fi

  ## We can't handle files with hardlinks because figuring out all of the files
  ## in a "hardlink pool" requires scanning the whole filesystem, which would
  ## result in an unacceptable performance hit for this script. We don't check
  ## directory hardlinks since directories can't have traditional hardlinks.
  if [ ! -d "${file_name_from_stat}" ]; then
    if (( hardlink_count > 1 )); then
      log error "\
File has unexpected hardlinks, cannot handle.
File name: '${file_name}'
File name from stat: '${file_name_from_stat}'
line: '${processed_config_line}'
" >&2
      return 1
    fi
  fi

  if [ -z "${existing_mode}" ]; then
    log error "Existing mode is empty. Stat output: '${stat_output}', line: '${processed_config_line}'" >&2
    return 1
  fi
  if [ -z "${existing_owner}" ]; then
    log error "Existing owner is empty. Stat output: '${stat_output}', line: '${processed_config_line}'" >&2
    return 1
  fi
  if [ -z "${existing_group}" ]; then
    log error "Existing group is empty. Stat output: '${stat_output}', line: '${processed_config_line}'" >&2
    return 1
  fi

  ## If a symlink was passed as input, return the original file's path rather
  ## than the symlink to avoid problems stemming from using the wrong path
  if [ -h "${file_name_from_stat}" ]; then
    file_name_from_stat="$(realpath "${file_name_from_stat}")"
  fi
}

print_usage(){
  safe_echo "Usage: ${0##*/} enable
       ${0##*/} disable [FILE|all]
       ${0##*/} print-policy
       ${0##*/} print-state
       ${0##*/} print-policy-applied-state
       ${0##*/} print-diagnostics

Examples:
  ${0##*/} enable
  ${0##*/} disable all
  ${0##*/} disable /usr/bin/newgrp" >&2
}

add_to_policy() {
  local file_name file_mode file_owner file_group updated_entry policy_idx \
    file_capabilities
  file_name="${1:-}"
  file_mode="${2:-}"
  file_owner="${3:-}"
  file_group="${4:-}"
  file_capabilities="${5:-}"
  updated_entry=false

  if [ -z "${file_name}" ]; then
    exit_code=207
    log error "Attempted to add a policy entry with an empty filename! file_mode='${file_mode}' file_onwer='${file_owner}' file_group='${file_group}' file_capabilities='${file_capabilities}'" >&2
    exit "${exit_code}"
  fi

  if [ -h "${file_name}" ]; then
    file_name="$(realpath "${file_name}")" || return 1
  fi

  for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do
    if [ "${policy_file_list[policy_idx]}" = "${file_name}" ]; then
      policy_mode_list[policy_idx]="${file_mode}"
      policy_user_owner_list[policy_idx]="${file_owner}"
      policy_group_owner_list[policy_idx]="${file_group}"
      policy_capability_list[policy_idx]="${file_capabilities}"
      updated_entry=true
      break
    fi
  done

  if [ "${updated_entry}" != 'true' ]; then
    policy_file_list+=( "${file_name}" )
    policy_mode_list+=( "${file_mode}" )
    policy_user_owner_list+=( "${file_owner}" )
    policy_group_owner_list+=( "${file_group}" )
    policy_capability_list+=( "${file_capabilities}" )
  fi
}

check_nosuid_whitelist() {
  local target_file match_white_list_entry

  target_file="${1:-}"

  ## Handle whitelists, if we're supposed to
  [  "${whitelists_disable_all}" = 'true' ] && return 0

  ## literal matching is intentional here
  [[ " ${policy_disable_white_list[*]} " =~ " ${target_file} " ]] && return 0

  ## literal matching is intentional here too
  [[ " ${policy_exact_white_list[*]} " =~ " ${target_file} " ]] && return 1

  for match_white_list_entry in "${policy_match_white_list[@]:-}"; do
    if [[ "${target_file}" == *"${match_white_list_entry}"* ]]; then
      return 1
    fi
  done

  return 0
}

load_early_nosuid_policy() {
  local target_file find_list_item

  target_file="${1:-}"

  # shellcheck disable=SC2185
  while IFS="" read -r -d "" find_list_item; do
    check_nosuid_whitelist "${find_list_item}" || continue

    ## sets:
    ## exiting_mode
    ## existing_owner
    ## existing_group
    output_stat "${find_list_item}"
    if [ -z "${file_name_from_stat}" ]; then
      continue
    fi

    ## -h file      True if file is a symbolic link.
    if [ -h "${find_list_item}" ]; then
      ## https://forums.whonix.org/t/disable-suid-binaries/7706/14
      log info "Skip symlink: '${find_list_item}'"
      continue
    fi

    if [ -d "${find_list_item}" ]; then
      log info "Skip directory: '${find_list_item}'"
      continue
    fi

    ## Remove suid / gid and execute permission for 'group' and 'others'.
    ## Similar to: chmod og-ugx /path/to/filename
    ## Removing execution permission is useful to make binaries such as 'su'
    ## fail closed rather than fail open if suid was removed from these.
    ## Do not remove read access since no security benefit and easier to
    ## manually undo for users.
    ## Are there suid or sgid binaries which are still useful if suid / sgid
    ## has been removed from these?
    local new_mode
    new_mode='744'

    add_to_policy "${file_name_from_stat}" "${new_mode}" "${existing_owner}" \
      "${existing_group}"
  done < <(safe_echo_nonewline "${target_file}" \
    | find -files0-from - -perm /u=s,g=s -print0)
}

## If the "target file" matches the start of the state file name, that's a
## likely match. This is used by load_late_nosuid_policy for detecting info
## about files that need SUID-locked that are in the state.
match_dir() {
  local base_str match_str base_arr match_arr base_idx

  base_str="${1}"
  match_str="${2}"
  if [ -z "${base_str}" ] || [ -z "${match_str}" ]; then
    exit_code=207
    log error "Empty base_str or match_str provided to match_dir! base_str: '${base_str}' match_str: '${match_str}'" >&2
    exit "${exit_code}"
  fi
  [[ "${base_str}" =~ '//' ]] && return 1
  [[ "${match_str}" =~ '//' ]] && return 1

  IFS='/' read -r -a base_arr <<< "${base_str}"
  IFS='/' read -r -a match_arr <<< "${match_str}"
  (( ${#base_arr[@]} > ${#match_arr[@]} )) && return 1

  for (( base_idx=0; base_idx < ${#base_arr[@]}; base_idx++ )); do
    if [ "${base_arr[base_idx]}" != "${match_arr[base_idx]}" ]; then
      return 1
    fi
  done

  return 0
}

load_late_nosuid_policy() {
  local target_file state_idx state_file_item state_user_owner_item \
    state_group_owner_item new_mode

  target_file="${1:-}"
  for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do
    state_file_item="${state_file_list[state_idx]}"
    check_nosuid_whitelist "${state_file_item}" || continue

    match_dir "${target_file}" "${state_file_item}" || continue

    if [ -h "${state_file_item}" ]; then
      ## https://forums.whonix.org/t/disable-suid-binaries/7706/14
      log info "Skip symlink: '${state_file_item}'"
      continue
    fi

    if [ -d "${state_file_item}" ]; then
      log info "Skip directory: '${state_file_item}'"
      continue
    fi

    state_user_owner_item="${state_user_owner_list[state_idx]}"
    state_group_owner_item="${state_group_owner_list[state_idx]}"
    new_mode='744'
    add_to_policy "${state_file_item}" "${new_mode}" \
      "${state_user_owner_item}" "${state_group_owner_item}"
  done
}

load_state_without_policy() {
  local line field_list

  ## Load the state file from disk
  if [ -f "${state_file}" ]; then
    while read -r line; do
      read -r -a field_list <<< "${line}"
      if (( ${#field_list[@]} != 4 )); then
        log info \
          "Invalid number of fields in state file line: '${line}'. Skipping."
        continue
      fi
      state_user_owner_list+=( "${field_list[0]}" )
      state_group_owner_list+=( "${field_list[1]}" )
      state_mode_list+=( "${field_list[2]}" )
      state_file_list+=( "${field_list[3]}" )
    done < "${state_file}"
  fi
}

load_state() {
  ## Config format:
  ##   path options
  ## where options is one of:
  ##   user_owner group_owner filemode [capability-setting]
  ##   [nosuid|exactwhitelist|matchwhitelist|disablewhitelist]
  ##
  ## Additionally, the special value 'whitelists_disable_all=true' is understood
  ## to mean that all whitelisting should be ignored.

  local config_file line field_list policy_nosuid_file_item policy_file_item

  ## Load configuration, deferring whitelist handling until later
  for config_file in \
    /usr/lib/permission-hardener.d/*.conf \
    /etc/permission-hardener.d/*.conf \
    /usr/local/etc/permission-hardener.d/*.conf \
    /etc/permission-hardening.d/*.conf \
    /usr/local/etc/permission-hardening.d/*.conf
  do
    if [ ! -f "${config_file}" ]; then
      continue
    fi

    while read -r line; do
      if [ -z "${line}" ]; then
        true 'DEBUG: line is empty. Skipping.'
        continue
      fi

      if [[ "${line}" =~ ^\s*# ]]; then
        continue
      fi

      if ! [[ "${line}" =~ ^[-0-9a-zA-Z._/[:space:]]*$ ]]; then
        exit_code=200
        log error "Line contains invalid characters: '${line}'" >&2
        ## Safer to exit with error in this case.
        ## https://forums.whonix.org/t/disable-suid-binaries/7706/59
        exit "${exit_code}"
      fi

      if [ "${line}" = 'whitelists_disable_all=true' ]; then
        whitelists_disable_all=true
        log info "whitelists_disable_all=true"
        continue
      fi

      processed_config_line="${line}"

      IFS=' ' read -r -a field_list <<< "${line}"

      case "${#field_list[@]}" in
        2|4|5) true;;
        *)
          exit_code=200
          log error "Line contains an invalid number of fields: '${line}'" >&2
          exit "${exit_code}"
          ;;
      esac

      # Strip trailing slash if appropriate
      field_list[0]="${field_list[0]%/}"

      case "${field_list[1]}" in
        'exactwhitelist')
          [ ! -e "${field_list[0]}" ] && continue
          policy_exact_white_list+=( "${field_list[0]}" )
          continue
          ;;
        'matchwhitelist')
          policy_match_white_list+=( "${field_list[0]}" )
          continue
          ;;
        'disablewhitelist')
          policy_disable_white_list+=( "${field_list[0]}" )
          continue
          ;;
        'nosuid')
          [ ! -e "${field_list[0]}" ] && continue
          policy_nosuid_file_list+=( "${field_list[0]}" )
          ;;
        *)
          [ ! -e "${field_list[0]}" ] && continue
          add_to_policy "${field_list[@]}"
          ;;
      esac
    done < "${config_file}"
  done

  ## We have to handle nosuid files at the end since the whitelist arrays need
  ## built first.
  for policy_nosuid_file_item in "${policy_nosuid_file_list[@]}"; do
    load_early_nosuid_policy "${policy_nosuid_file_item}"
  done

  load_state_without_policy

  ## Find any files in the policy that don't already have a matching file in
  ## the state. Add those files to the state, and save them to the state file
  ## as well.
  for policy_file_item in "${policy_file_list[@]}"; do
    if [[ " ${state_file_list[*]} " =~ " ${policy_file_item} " ]]; then
      continue
    fi
    output_stat "${policy_file_item}"
    if [ -z "${file_name_from_stat}" ]; then
      continue
    fi
    state_file_list+=( "${file_name_from_stat}" )
    state_user_owner_list+=( "${existing_owner}" )
    state_group_owner_list+=( "${existing_group}" )
    state_mode_list+=( "${existing_mode}" )
    # shellcheck disable=SC2086
    echo_wrapper_audit silent dpkg-statoverride \
      ${dpkg_admindir_parameter_existing_mode} \
      --add "${existing_owner}" "${existing_group}" "${existing_mode}" \
      "${file_name_from_stat}"
  done

  ## Fix up nosuid policies using state information
  for policy_nosuid_file_item in "${policy_nosuid_file_list[@]}"; do
    load_late_nosuid_policy "${policy_nosuid_file_item}"
  done
}

apply_policy() {
  local policy_idx did_state_update state_idx

  ## Modify the in-memory state so that all items that the policy affects match
  ## the policy. DO NOT save these changes to the state file!
  for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do
    did_state_update=false
    for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do
      if [ "${state_file_list[state_idx]}" = "${policy_file_list[policy_idx]}" ]; then
        state_user_owner_list[state_idx]="${policy_user_owner_list[policy_idx]}"
        state_group_owner_list[state_idx]="${policy_group_owner_list[policy_idx]}"
        state_mode_list[state_idx]="${policy_mode_list[policy_idx]}"
        did_state_update=true
        break
      fi
    done
    if [ "${did_state_update}" = 'false' ]; then
      exit_code=206
      log error \
        "File exists in policy but not in state! File: '${policy_file_list[policy_idx]}'"
      exit "${exit_code}"
    fi
  done
}

commit_policy() {
  local policy_idx state_idx state_file_item \
    state_user_owner_item state_group_owner_item \
    state_mode_item orig_main_statoverride_db orig_new_statoverride_db \
    policy_file_item policy_capability_item

  ## Check each file on the filesystem against the state, and update it if the
  ## state does not match. Also ensure the consistency of the new_mode database
  ## so that people can compare the original permissions of files with the new
  ## permissions.
  orig_main_statoverride_db="$(dpkg-statoverride --list)" || true
  # shellcheck disable=SC2086
  orig_new_statoverride_db="$(dpkg-statoverride ${dpkg_admindir_parameter_new_mode} --list)" || true

  for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do
    state_file_item="${state_file_list[state_idx]}"
    state_user_owner_item="${state_user_owner_list[state_idx]}"
    state_group_owner_item="${state_group_owner_list[state_idx]}"
    state_mode_item="${state_mode_list[state_idx]}"

    ## Get rid of leading zeros, stat doesn't output them due to how we use it.
    ## Using BASH_REMATCH is faster than sed. We capture all leading zeros into
    ## one group, and the rest of the string into a second group. The second
    ## group is the string we want. BASH_REMATCH[0] is the entire string,
    ## BASH_REMATCH[1] is the first match that we want to discard, and
    ## BASH_REMATCH[2] is the desired second group.
    if [[ "${state_mode_item}" =~ ^(0*)(.*) ]]; then
      state_mode_item="${BASH_REMATCH[2]}"
    else
      exit_code=208
      log error "'Impossible' regex match failure in commit_policy! Regex: '^(0*)(.*)' String (state_mode_item): '${state_mode_item}'" >&2
      exit "${exit_code}"
    fi

    output_stat "${state_file_item}"
    if [ -z "${file_name_from_stat}" ]; then
      continue
    fi

    if [ "${existing_owner}" != "${state_user_owner_item}" ] \
      || [ "${existing_group}" != "${state_group_owner_item}" ] \
      || [ "${existing_mode}" != "${state_mode_item}" ]; then
      ## Anchor user/group lookups to line boundaries to prevent substring
      ## matches (e.g. 'oot' must not match 'root:...')
      if ! [[ $'\n'"${passwd_file_contents}" \
        =~ $'\n'"${state_user_owner_item}:" ]]; then
        log error "Owner from config does not exist: '${state_user_owner_item}'" >&2
        continue
      fi

      if ! [[ $'\n'"${group_file_contents}" \
        =~ $'\n'"${state_group_owner_item}:" ]]; then
        log error "Group from config does not exist: '${state_group_owner_item}'" >&2
        continue
      fi
      ## Remove and reapply in main list
      ## Anchor to file field and line boundary to prevent substring path
      ## matches
      if [[ "${orig_main_statoverride_db}"$'\n' \
        =~ [[:space:]]"${file_name_from_stat}"$'\n' ]]; then
        echo_wrapper_ignore silent dpkg-statoverride --remove \
          "${file_name_from_stat}"
      fi
      echo_wrapper_audit verbose dpkg-statoverride --add --update \
        "${state_user_owner_item}" "${state_group_owner_item}" \
        "${state_mode_item}" "${file_name_from_stat}"

      ## Update item in secondary list
      if [[ "${orig_new_statoverride_db}"$'\n' \
        =~ [[:space:]]"${file_name_from_stat}"$'\n' ]]; then
        # shellcheck disable=SC2086
        echo_wrapper_ignore silent dpkg-statoverride \
          ${dpkg_admindir_parameter_new_mode} --remove \
          "${file_name_from_stat}"
      fi
      # shellcheck disable=SC2086
      echo_wrapper_audit verbose dpkg-statoverride \
        ${dpkg_admindir_parameter_new_mode} --add \
        "${state_user_owner_item}" "${state_group_owner_item}" \
        "${state_mode_item}" "${file_name_from_stat}"
    fi
  done

  ## Apply capability hardening, dpkg-statoverride can't handle this so we have
  ## to do this manually
  for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do
    policy_file_item="${policy_file_list[policy_idx]}"
    policy_capability_item="${policy_capability_list[policy_idx]}"
    if [ -z "${policy_capability_item}" ]; then
      continue
    fi

    if [ "${policy_capability_item}" = 'none' ]; then
      echo_wrapper_ignore verbose setcap -r "${policy_file_item}"
      if [ -n "$(getcap -- "${policy_file_item}")" ]; then
        exit_code=205
        log error \
          "Removing capabilities failed. File: '${policy_file_item}'" >&2
        continue
      fi
    else
      if ! capsh --print \
        | grep --fixed-strings -- "Bounding set" \
        | grep -- "${policy_capability_item}" >/dev/null; then
        log error \
          "Capability from config does not exist: '${policy_capability_item}'" \
          >&2
        continue
      fi

      ## feature request: dpkg-statoverride: support for capabilities
      ## https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=502580
      echo_wrapper_audit verbose setcap "${policy_capability_item}+ep" \
        -- "${policy_file_item}"
    fi
  done

  log notice "\
To compare the current and previous permission modes, install 'meld' (or preferred diff tool) for comparison of file mode changes:
    sudo apt install --no-install-recommends meld
    meld ${store_dir}/existing_mode/statoverride ${store_dir}/new_mode/statoverride"
}

undo_policy_for_file() {
  local undo_file state_idx state_file_item did_undo \
    undo_all verbose orig_main_statoverride_db orig_new_statoverride_db \
    state_user_owner_item state_group_owner_item state_mode_item

  undo_file="${1}"
  undo_all=false
  verbose='--verbose'
  if [ "${undo_file}" = 'all' ]; then
    undo_all=true
    verbose=''
  fi

  if [ ! -f "${state_file}" ]; then
    true 'DEBUG: State file does not exist, hardening was not applied before.'
    return 0
  fi

  did_undo=false

  for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do
    state_file_item="${state_file_list[state_idx]}"
    if [ "${undo_all}" = 'true' ]; then
      undo_file="${state_file_item}"
    fi

    if [ "${state_file_item}" = "${undo_file}" ]; then
      orig_main_statoverride_db="$(dpkg-statoverride --list)" || true
      # shellcheck disable=SC2086
      orig_new_statoverride_db="$(dpkg-statoverride ${dpkg_admindir_parameter_new_mode} --list)" || true

      if [[ "${orig_main_statoverride_db}"$'\n' \
        =~ [[:space:]]"${undo_file}"$'\n' ]]; then
        echo_wrapper_ignore silent dpkg-statoverride --remove \
          "${undo_file}"
      fi

      if [[ "${orig_new_statoverride_db}"$'\n' \
        =~ [[:space:]]"${undo_file}"$'\n' ]]; then
        # shellcheck disable=SC2086
        echo_wrapper_ignore silent dpkg-statoverride \
          ${dpkg_admindir_parameter_new_mode} --remove \
          "${undo_file}"
      fi

      if [ -e "${undo_file}" ]; then
        state_user_owner_item="${state_user_owner_list[state_idx]}"
        state_group_owner_item="${state_group_owner_list[state_idx]}"
        state_mode_item="${state_mode_list[state_idx]}"
        # shellcheck disable=SC2086
        chown ${verbose} -- "${state_user_owner_item}:${state_group_owner_item}" \
          "${undo_file}" || exit_code=202
        ## chmod needs to be run after chown since chown removes suid.
        # shellcheck disable=SC2086
        chmod ${verbose} "${state_mode_item}" "${undo_file}" || exit_code=203
      else
        log info "File does not exist: '${undo_file}'"
      fi
      did_undo=true

      if [ "${undo_all}" = 'false' ]; then
        break
      fi
    fi
  done

  if [ "${did_undo}" = 'false' ]; then
    log notice "The specified file is not hardened, leaving unchanged.

  File '${undo_file}' has not been removed from SUID Disabler and Permission Hardener during this invocation. This is expected if no policy was ever applied to the file before.

  This program expects the full path to the file. Example:
    $0 disable /usr/bin/newgrp  # absolute path: works
    $0 disable newgrp           # relative path: does not work

  To remove all:
    $0 disable all

  This change might not be permanent. For full instructions, see:
    https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener

  To view list of changed by SUID Disabler and Permission Hardener:
    https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener#View_List_of_Permissions_Changed_by_SUID_Disabler_and_Permission_Hardener

  For re-enabling any specific SUID binary:
    https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener#Re-Enable_Specific_SUID_Binaries

  For completely disabling SUID Disabler and Permission Hardener:
    https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener#Disable_SUID_Disabler_and_Permission_Hardener"
  fi
}

print_columns() {
  local format_str
  format_str=''
  for _ in "$@"; do
    format_str="${format_str}%s\t"
  done
  format_str="${format_str}\n"
  ## Using a dynamically generated format string on purpose.
  # shellcheck disable=SC2059
  printf "${format_str}" "$@"
}

print_policy() {
  local policy_idx

  print_columns 'File' 'User' 'Group' 'Mode' 'Capabilities'

  for (( policy_idx=0; policy_idx < ${#policy_file_list[@]}; policy_idx++ )); do
    print_columns \
      "${policy_file_list[policy_idx]}" \
      "${policy_user_owner_list[policy_idx]}" \
      "${policy_group_owner_list[policy_idx]}" \
      "${policy_mode_list[policy_idx]}" \
      "${policy_capability_list[policy_idx]}"
  done
}

print_state() {
  local state_idx

  print_columns 'File' 'User' 'Group' 'Mode'
  for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do
    print_columns \
      "${state_file_list[state_idx]}" \
      "${state_user_owner_list[state_idx]}" \
      "${state_group_owner_list[state_idx]}" \
      "${state_mode_list[state_idx]}"
  done
}

print_raw_policy_config() {
  local config_file
  for config_file in \
    /usr/lib/permission-hardener.d/*.conf \
    /etc/permission-hardener.d/*.conf \
    /usr/local/etc/permission-hardener.d/*.conf \
    /etc/permission-hardening.d/*.conf \
    /usr/local/etc/permission-hardening.d/*.conf
  do
    if [ ! -f "${config_file}" ]; then
      continue
    fi
    echo "*** begin ${config_file} ***"
    cat "${config_file}"
    echo "***   end ${config_file} ***"
  done
}

print_raw_state() {
  local state_file
  for state_file in "${store_dir}/existing_mode/statoverride" \
    "${store_dir}/new_mode/statoverride"; do
    echo "*** begin ${state_file} ***"
    if [ -f "${state_file}" ]; then
      cat "${state_file}"
    else
      echo '(file does not exist)'
    fi
    echo "***   end ${state_file} ***"
  done
}

print_fs_audit() {
  local state_idx state_file_item state_user_owner_item state_group_owner_item \
    state_mode_item

  echo 'Legend:'
  echo '... - Warning about an unusual, but not necessarily wrong, condition'
  echo '!!! - Warning about an unusual and definitely wrong condition'
  echo '*** - File permission data, actual state on filesystem is consistent with policy'
  echo '^^^ - File permission data, actual state on filesystem is inconsistent with policy'
  echo 'vvv - File permissions specified by state, always shown after a ^^^ item'
  echo

  for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do
    state_file_item="${state_file_list[state_idx]}"
    state_user_owner_item="${state_user_owner_list[state_idx]}"
    state_group_owner_item="${state_group_owner_list[state_idx]}"
    state_mode_item="${state_mode_list[state_idx]}"

    ## Get rid of leading zeros, stat doesn't output them due to how we use it.
    ## Using BASH_REMATCH is faster than sed. We capture all leading zeros into
    ## one group, and the rest of the string into a second group. The second
    ## group is the string we want. BASH_REMATCH[0] is the entire string,
    ## BASH_REMATCH[1] is the first match that we want to discard, and
    ## BASH_REMATCH[2] is the desired second group.
    if [[ "${state_mode_item}" =~ ^(0*)(.*) ]]; then
      state_mode_item="${BASH_REMATCH[2]}"
    else
      exit_code=208
      log error "'Impossible' regex match failure in print_fs_audit! Regex: '^(0*)(.*)' String (state_mode_item): '${state_mode_item}'" >&2
      exit "${exit_code}"
    fi

    output_stat "${state_file_item}"
    if [ -z "${file_name_from_stat}" ]; then
      echo "... '${state_file_item}' does not exist"
      continue
    fi

    if [ "${existing_owner}" != "${state_user_owner_item}" ] \
      || [ "${existing_group}" != "${state_group_owner_item}" ] \
      || [ "${existing_mode}" != "${state_mode_item}" ]; then
      if ! [[ $'\n'"${passwd_file_contents}" \
        =~ $'\n'"${state_user_owner_item}:" ]]; then
        echo "!!! Owner from config does not exist: '${state_user_owner_item}'"
        continue
      fi

      if ! [[ $'\n'"${group_file_contents}" \
        =~ $'\n'"${state_group_owner_item}:" ]]; then
        echo "!!! Group from config does not exist: '${state_group_owner_item}'"
        continue
      fi

      echo "^^^ ${file_name_from_stat} ${existing_owner}:${existing_group} ${existing_mode}"
      echo "vvv ${file_name_from_stat} ${state_user_owner_item}:${state_group_owner_item} ${state_mode_item}"
    else
      echo "*** ${file_name_from_stat} ${existing_owner}:${existing_group} ${existing_mode}"
    fi
  done
}

reset_global_vars() {
  ## Global variables
  policy_file_list=()
  policy_user_owner_list=()
  policy_group_owner_list=()
  policy_mode_list=()
  policy_capability_list=()
  policy_exact_white_list=()
  policy_match_white_list=()
  policy_disable_white_list=()
  policy_nosuid_file_list=()
  state_file_list=()
  state_user_owner_list=()
  state_group_owner_list=()
  state_mode_list=()
  whitelists_disable_all=false
  existing_mode=''
  existing_owner=''
  existing_group=''
  processed_config_line=''
  file_name_from_stat=''
  passwd_file_contents="$(getent passwd)"
  group_file_contents="$(getent group)"
  exit_code=0
}

reset_global_vars

## Setup and sanity checking
if [ "$(id -u)" != '0' ]; then
  log error "Not running as root, aborting."
  exit 1
fi

mkdir --parents "${store_dir}/existing_mode"
mkdir --parents "${store_dir}/new_mode"

echo_wrapper_audit silent which capsh getcap setcap stat find \
  dpkg-statoverride getent grep 1>/dev/null

## Command parsing and execution
case "${1:-}" in
  enable)
    shift
    load_state
    apply_policy
    commit_policy
    ;;
  disable)
    shift
    case "${1:-}" in
      "")
        print_usage
        exit 1
        ;;
      *)
        load_state_without_policy
        undo_policy_for_file "${1}"
        ;;
    esac
    ;;
  print-policy)
    load_state
    print_policy
    ;;
  print-state)
    load_state
    print_state
    ;;
  print-policy-applied-state)
    load_state
    apply_policy
    print_state
    ;;
  print-diagnostics)
    echo '=== BEGIN PERMISSION-HARDENER DIAGNOSTICS ==='

    echo '--- BEGIN State without policy ---'
    load_state_without_policy
    print_state
    echo '---   END State without policy ---'

    reset_global_vars

    echo '--- BEGIN Policy without state ---'
    load_state
    print_policy
    echo '---   END Policy without state ---'

    reset_global_vars

    echo '--- BEGIN Policy-applied-state ---'
    load_state
    apply_policy
    print_state
    echo '---   END Policy-applied state ---'

    reset_global_vars

    echo '--- BEGIN Master dpkg-statoverride database ---'
    dpkg-statoverride --list
    echo '---   END Master dpkg-statoverride database ---'

    echo '--- BEGIN Raw policy configuration ---'
    print_raw_policy_config
    echo '---   END Raw policy configuration ---'

    echo '--- BEGIN Raw state data ---'
    print_raw_state
    echo '---   END Raw state data ---'

    echo '--- BEGIN Filesystem state audit ---'
    load_state
    apply_policy
    print_fs_audit
    echo '---   END Filesystem state audit ---'

    echo '===  END PERMISSION-HARDENER DIAGNOSTICS ==='
    ;;
  -h|--help)
    print_usage
    exit 0
    ;;
  *)
    print_usage
    exit 1
    ;;
esac

## Exit
if test "${exit_code}" != "0"; then
  log error "Exiting with non-zero exit code: '${exit_code}'" >&2
fi

exit "${exit_code}"
