#!/bin/bash

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

#set -x
set -o errexit
set -o nounset
set -o pipefail
set -o errtrace
shopt -s inherit_errexit
shopt -s shift_verbose

true "$0: START."

MYDIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" && pwd )"

if [ "$MYDIR" = "/usr/bin" ]; then
   true "INFO: Run from: /usr/bin"
   ## XXX: hardcoded path
   derivative_maker_source_code_dir="$HOME/derivative-maker"
else
   true "INFO: Run from: source code folder"
   derivative_maker_source_code_dir="$(cd -- "$MYDIR" && cd -- "../../../../../" && pwd)"
fi

## helper-scripts location: prefer HELPER_SCRIPTS_PATH (exported by the
## derivative-maker build's help-steps during bootstrap, before
## helper-scripts is installed); fall back to the in-tree copy. Export it so
## run-via-helper-scripts (below) inherits this same tree instead of
## re-deriving its own.
[ -v HELPER_SCRIPTS_PATH ] || HELPER_SCRIPTS_PATH="${derivative_maker_source_code_dir}/packages/kicksecure/helper-scripts"
export HELPER_SCRIPTS_PATH
grep_find_unicode_wrapper="${HELPER_SCRIPTS_PATH}/usr/bin/grep-find-unicode-wrapper"
## run-via-helper-scripts sets HELPER_SCRIPTS_PATH + PYTHONPATH so the in-tree
## Python helpers (here the security-hardened 'stcat') run during the
## build bootstrap, before helper-scripts is installed.
run_via_helper_scripts="${HELPER_SCRIPTS_PATH}/usr/bin/run-via-helper-scripts"

## Sanity tests.
test -d "$derivative_maker_source_code_dir"
test -x "$grep_find_unicode_wrapper"
test -x "$run_via_helper_scripts"

## Need to 'cd' for relative paths.
cd -- "$derivative_maker_source_code_dir"

declare -A symlink_whitelist

symlink_whitelist["./live-build/share/bootloaders/isolinux/isolinux.bin"]="/usr/lib/ISOLINUX/isolinux.bin"
symlink_whitelist["./live-build/share/bootloaders/isolinux/ldlinux.c32"]="/usr/lib/syslinux/modules/bios/ldlinux.c32"
symlink_whitelist["./live-build/share/bootloaders/syslinux_common/hdt.c32"]="/usr/lib/syslinux/modules/bios/hdt.c32"
symlink_whitelist["./live-build/share/bootloaders/syslinux_common/libgpl.c32"]="/usr/lib/syslinux/modules/bios/libgpl.c32"
symlink_whitelist["./live-build/share/bootloaders/syslinux_common/libutil.c32"]="/usr/lib/syslinux/modules/bios/libutil.c32"
symlink_whitelist["./live-build/share/bootloaders/syslinux_common/libcom32.c32"]="/usr/lib/syslinux/modules/bios/libcom32.c32"
symlink_whitelist["./live-build/share/bootloaders/syslinux_common/vesamenu.c32"]="/usr/lib/syslinux/modules/bios/vesamenu.c32"
symlink_whitelist["./live-build/share/bootloaders/syslinux_common/libmenu.c32"]="/usr/lib/syslinux/modules/bios/libmenu.c32"
symlink_whitelist["./live-build/share/bootloaders/pxelinux/pxelinux.0"]="/usr/lib/PXELINUX/pxelinux.0"
symlink_whitelist["./live-build/share/bootloaders/pxelinux/ldlinux.c32"]="/usr/lib/syslinux/modules/bios/ldlinux.c32"
symlink_whitelist["./live-build/data/debian-cd/bullseye"]="squeeze/"
symlink_whitelist["./live-build/data/debian-cd/jessie"]="squeeze"
symlink_whitelist["./live-build/data/debian-cd/stretch"]="squeeze"
symlink_whitelist["./live-build/data/debian-cd/unstable"]="sid"
symlink_whitelist["./live-build/data/debian-cd/buster"]="squeeze"
symlink_whitelist["./live-build/data/debian-cd/kali-last-snapshot"]="sid"
symlink_whitelist["./live-build/data/debian-cd/sid"]="trixie"
symlink_whitelist["./live-build/data/debian-cd/bookworm"]="squeeze/"
symlink_whitelist["./live-build/data/debian-cd/kali-dev"]="sid"
symlink_whitelist["./live-build/data/debian-cd/kali-rolling"]="sid"
symlink_whitelist["./live-build/data/debian-cd/testing"]="trixie"
symlink_whitelist["./live-build-data/d-i-branding/logo_installer_dark.png"]="logo_debian.png"
symlink_whitelist["./live-build-data/d-i-branding/logo_debian_dark.png"]="logo_debian.png"
symlink_whitelist["./live-build-data/d-i-branding/logo_installer.png"]="logo_debian.png"
symlink_whitelist["./qubes/qubes-template-whonix/whonix-workstation"]="whonix-gateway"

## Git-tracked symlinks (mode 120000), recursing into submodules. With
## 'core.symlinks=false' a symlink is checked out as a regular text file
## whose content is the link target, so 'find -type l' misses it and the
## content checks further below would false-flag it. Enumerating from git
## metadata makes symlink handling independent of 'core.symlinks'.
## 'git ls-files --stage' prints "<mode> <object> <stage>\t<path>"; mode
## 120000 is a symlink. Capture first (a git failure then trips errexit; a
## process substitution would mask it), then split on the tab and read the
## mode with a parameter expansion -- no awk, no subshell around the loop, so
## the associative array persists.
declare -A git_symlink_paths
git_ls_files_stage="$(git ls-files --recurse-submodules --stage)"
while IFS=$'\t' read -r git_index_meta git_symlink_path; do
   [ "${git_index_meta%% *}" = "120000" ] || continue
   git_symlink_paths["$git_symlink_path"]=1
done <<< "$git_ls_files_stage"

read_link_target() {
   ## Symlink target, independent of 'core.symlinks': a real symlink via
   ## 'readlink'; a symlink checked out as a regular file (core.symlinks
   ## =false) stores its target as the file content. Read it with the
   ## security-hardened 'stcat', run via run-via-helper-scripts so it resolves
   ## (with its stdisplay module on PYTHONPATH) during the build's
   ## bootstrap before helper-scripts is installed. stcat does no option
   ## parsing, so a leading-dash path is safe without '--'.
   local path
   path="$1"
   if [ -L "$path" ]; then
      readlink -- "$path"
   else
      "$run_via_helper_scripts" stcat "$path"
   fi
}

check_symlink() {
   local symlink
   symlink="$1"
   local target
   target="$(read_link_target "$symlink")"

   if [ "${symlink_whitelist[$symlink]+_}" ]; then
      if [ "${symlink_whitelist[$symlink]}" == "$target" ]; then
         printf '%s\n' "$0: INFO: Whitelisted symlink: '$symlink' -> '$target'" >/dev/null
         return 0
      fi
      printf '%s\n' "$0: ERROR: Whitelisted symlink: '$symlink' but wrong target '$target'" >&2
      return 1
   fi

   printf '%s\n' "$0: ERROR: NOT whitelisted symlink: '$symlink' -> '$target'" >&2
   return 1
}

find_symlinks() {
   local symlink found_symlink
   local -A seen_symlink
   local -a all_symlinks=()
   found_symlink=""

   ## Authoritative: every git-tracked symlink, normalized to the './'
   ## prefix the whitelist keys use, independent of 'core.symlinks' (a
   ## 'find -type l' alone misses the ones materialized as regular files
   ## when 'core.symlinks=false').
   for symlink in "${!git_symlink_paths[@]}"; do
      symlink="./$symlink"
      [ -v "seen_symlink[$symlink]" ] && continue
      seen_symlink["$symlink"]=1
      all_symlinks+=("$symlink")
   done

   ## Plus any remaining (e.g. untracked) filesystem symlinks.
   ## Need to use '"."' for relative paths. Prune '.git' directories only
   ## (the old '-iwholename "*.git*"' also excluded '.github/',
   ## '.gitignore', ... leaving filesystem symlinks under those
   ## unvalidated).
   while IFS= read -r -d '' symlink; do
      [ -v "seen_symlink[$symlink]" ] && continue
      seen_symlink["$symlink"]=1
      all_symlinks+=("$symlink")
   done < <(find "." -path '*/.git' -prune -o -type l -print0)

   for symlink in ${all_symlinks[@]+"${all_symlinks[@]}"}; do
      if check_symlink "$symlink" ; then
         continue
      fi
      found_symlink=true
      printf '%s\n' "----------" >&2
   done

   if [ "$found_symlink" = "true" ]; then
      exit 1
   fi
}

find_symlinks

## overwrite with '|| true' because `grep` exits non-zero if no match was found.
##
## Using because a real name contains a special character.
## XXX: This is clearly a non-ideal solution but fixing this is an issue for
##      whole Free and Open Source community. See also:
##      https://www.kicksecure.com/wiki/Unicode
##      https://forums.whonix.org/t/detecting-malicious-unicode-in-source-code-and-pull-requests/13754
## --exclude=LICENSE
## --exclude=lkrg-openrc.sh
grep_find_unicode_wrapper_output="$(\
   "$grep_find_unicode_wrapper" \
      --recursive \
      --binary-files=without-match \
      --exclude=control.authcookie \
      --exclude=LICENSE \
      --exclude=lkrg-openrc.sh \
      --exclude=changelog \
      --exclude=changelog.upstream \
      --exclude-dir=.git \
      -- \
      "./" \
   )" \
   || true

whitelist_list=(
   './live-build/share/bootloaders/syslinux_common/menu.cfg'
   './live-build/debian/control'
   './live-build/debian/copyright'
   './live-build/manpages/po/fr/.*'
   './live-build/manpages/fr/*'
   './live-build/manpages/ja/*'
   './live-build/manpages/po/ja/.*'
   './packages/kicksecure/kicksecure-base-files/debian/copyright'
   './packages/kicksecure/helper-scripts/usr/lib/python3/dist-packages/stdisplay/tests/stdisplay.py'
   './windows/Whonix-Starter/COPYING.txt'
)
whitelist_pattern="($(IFS=$'\n'; printf '%s' "${whitelist_list[*]}" | sed -z 's/\n/|/g'))";

filtered_output=$(printf '%s\n' "$grep_find_unicode_wrapper_output" | grep --invert-match --extended-regexp -- "$whitelist_pattern" || true)

if [ -z "$filtered_output" ]; then
   true "INFO: No Unicode issues found, everything is OK."
else
   printf '%s\n' "$filtered_output" >&2

   printf '%s\n' "\
$0: ERROR: Unicode found!

See also:
- https://www.kicksecure.com/wiki/Unicode
- https://forums.whonix.org/t/detecting-malicious-unicode-in-source-code-and-pull-requests/13754
"
   exit 1
fi

## Check for trailing spaces.
## TODO: remove '--exclude-dir=live-build' once live-build is free of trailing spaces.
if grep \
      --line-number \
      --recursive \
      --binary-files=without-match \
      --exclude=control.authcookie \
      --exclude=LICENSE \
      --exclude=lkrg-openrc.sh \
      --exclude=changelog \
      --exclude=changelog.upstream \
      --exclude-dir=.git \
      --exclude-dir=live-build \
      -- \
      '[[:blank:]]$' ; then
   true "\
$0: ERROR: Trailing whitespaces found!"
   exit 1
else
   true "INFO: No trailing whitespace issues found, everything is OK."
fi

## Check for missing newline at the end of the file (EOF).
##
## Thanks to:
## Julien Palard
## https://stackoverflow.com/a/25686825/2605155
##
## grep -Pzlvr '\x0a$'
## --perl-regexp --null --files-with-matches --invert-match
##
## '--null-data' unfortunately breaks '--binary-files'. Hence,
## binary file types need to be manually excluded.
grep_missing_newline_result_raw="$( \
   grep \
      --recursive \
      --binary-files=without-match \
      --exclude='*.png' --exclude='*.jpg' --exclude='*.jpeg' \
      --exclude='*.ico' --exclude='*.svg' --exclude='*.ai' \
      --exclude='*.gpg' --exclude='*.kbx' --exclude='*.pf2' \
      --perl-regexp --null-data --files-with-matches --invert-match \
      --exclude="control.authcookie" \
      --exclude="RecommendedTBBVersions" \
      --exclude-dir="live-build" \
      --exclude-dir=".git" \
      -- \
      '\x0a$'
   )" \
   || true

true "grep_missing_newline_result_raw:
$grep_missing_newline_result_raw"

grep_missing_newline_result_filtered=()

## grep --files-with-matches prints one filename per line; iterate
## line by line via while-read so paths containing spaces are not
## split into multiple fake "files" (the prior `for ... in $var`
## form word-split on every whitespace character).
while IFS= read -r file_name ; do
   [ -z "$file_name" ] && continue

   ## git style symlinks actually not allowed.
#    if [ "$file_name" = "packages/kicksecure/helper-scripts/usr/bin/append-once" ]; then
#       size="$(stat -c '%s' -- "$file_name")"
#       if [ "$size" = "6" ]; then
#          continue
#       fi
#    fi
#    if [ "$file_name" = "packages/kicksecure/helper-scripts/usr/bin/overwrite" ]; then
#       size="$(stat -c '%s' -- "$file_name")"
#       ## Coincidentally also 6 bytes size.
#       if [ "$size" = "6" ]; then
#          continue
#       fi
#    fi

   ## Skip git-tracked symlinks. With 'core.symlinks=false' they
   ## materialize as regular files (content = link target, no trailing
   ## newline) and would otherwise be false-flagged here as a missing
   ## newline. They are verified separately, against the symlink
   ## whitelist, in find_symlinks() above. 'git ls-files' paths have no
   ## './' prefix, matching grep's output here.
   if [ -n "${git_symlink_paths[$file_name]+_}" ]; then
      continue
   fi

   grep_missing_newline_result_filtered+=("$file_name")
done <<< "$grep_missing_newline_result_raw"

if [ "${#grep_missing_newline_result_filtered[@]}" -eq "0" ]; then
   true "INFO: No missing newlines at the end of file issues found, everything is OK."
else
   printf '%s\n' "$0: ERROR: Missing newlines at the end of file found! grep_missing_newline_result_filtered:
${grep_missing_newline_result_filtered[*]}"
   exit 1
fi

## Code duplication. Copied from helper-scripts/usr/bin/modeline-show and adapted.
modeline_regex='\(\(vi\|[Vv]im\([<=>]\?[0-9]\+\)\?\|[[:space:]]ex\):\|\([[:space:]]\|^\)-\*-\|Local'
modeline_regex+=' Variables:\)'
if grep \
      --line-number \
      --recursive \
      --binary-files=without-match \
      --exclude=dm-check-unicode \
      --exclude-dir=.git \
      --exclude-dir=grml-debootstrap \
      -- \
      "${modeline_regex}"; then
  true "\
$0: ERROR: Vim or Emacs modelines found!"
  exit 1
else
  true "INFO: No Vim or Emacs modelines found, everything is OK."
fi

true "$0: END."
