#!/bin/bash
set -euo pipefail

echoerr() {
  # write to stderr
  printf '%s\n' "$*" 1>&2
}

usage() {
  local rc="${1:-0}" out=1
  [ "$rc" -eq 0 ] || out=2
  {
    echo " lostfiles 5.1"
    echo " Usage: $0 [-s] [-z] [-h]"
    echo "      Calling without an option runs in relaxed mode sorting by name"
    echo "  -h  display this help"
    echo "  -s  optionally define strict mode"
    echo "  -z  optionally sort results by size"
  } >&"$out"
  exit "$rc"
}

# setup defaults which user can override with switches
# comm's output below is already LC_ALL=C name-sorted, so the default
# postprocess is a no-op; only -z (sort_by_size) needs a further pass.
postprocess="cat"
make_filter="make_relaxed_filter"
# post-filter applied to the candidate list; relaxed mode overrides this
postfilter="cat"
include_list=()
exclude_list=()
search_paths=()

while getopts 'hsz' OPTION; do
  case "$OPTION" in
    z)
      postprocess="sort_by_size"
      ;;
    s)
      make_filter="make_strict_filter"
      ;;
    h)
      usage
      ;;
    *)
      usage 1
      ;;
  esac
done
shift $((OPTIND -1))

if (( EUID != 0 )); then
  echoerr "You must run this script as root."
  exit 1
fi

# fd's failures are deliberately swallowed later on (permission-denied noise
# is expected while scanning) so a missing binary must be caught here up
# front; otherwise it would silently produce an empty, falsely-clean report.
deps=(fd pacman comm du sort sed grep gawk stat tr)
missing_deps=()
for dep in "${deps[@]}"; do
  command -v "$dep" >/dev/null 2>&1 || missing_deps+=("$dep")
done
if (( ${#missing_deps[@]} )); then
  for dep in "${missing_deps[@]}"; do
    echoerr "missing required dependency: $dep"
  done
  exit 1
fi

# Sorts a list of file names by size
sort_by_size() {
  tr '\n' '\0' | du -s --files0-from=- 2>/dev/null | sort -rn -k1
}

# A path is only trusted for reading local configuration if it is owned by
# root (or the invoking sudo user) and not writable by group or other;
# otherwise a compromised/loosely-permissioned lostfiles.conf sitting next to
# the script could inject arbitrary includes/excludes.
is_trusted() {
  local path="$1" owner mode group_digit other_digit
  read -r owner mode < <(stat -Lc '%u %a' "$path" 2>/dev/null) || return 1
  if [ "$owner" != 0 ] && [ "$owner" != "${SUDO_UID:-}" ]; then
    return 1
  fi
  group_digit=${mode: -2:1}
  other_digit=${mode: -1:1}
  (( (group_digit & 2) == 0 && (other_digit & 2) == 0 ))
}

# reads a list of paths from a configuration file at the specified path
# and adds them to the global include/exclude list variables
read_config() {
  local file="$1" line
  [ -f "$file" ] || return 0

  while IFS= read -r line || [ -n "$line" ]; do
    case "$line" in
      '#'*|'') ;;
      '+/'*) include_list+=("${line:1}") ;;
      '-/'*) exclude_list+=("${line:1}") ;;
      *)
        echoerr 'Invalid configuration file.'
        echoerr 'All lines in '"$file"' must be comments (#) or an absolute path prefixed with + or -.'
        exit 1
        ;;
    esac
  done < "$file"
}

# Removes symbolic links under /etc/systemd from the candidate list (read on
# stdin). `systemctl enable` creates these and they are not owned by any
# package. This cannot be expressed as an fd --exclude glob because it is
# type-based, so gather the links once (a tiny subtree) and filter them out
# with grep -- testing every candidate line in bash is orders of magnitude
# slower. An empty pattern file simply keeps every line. This scans only
# root-controlled /etc/systemd, so a newline-safe --print0 scan isn't needed.
drop_systemd_symlinks() {
  LC_ALL=C grep -vxF -f <(
    fd --absolute-path --hidden --no-ignore --no-global-ignore-file \
      --type symlink '' /etc/systemd 2>/dev/null | sed -e 's|/$||'
  ) - || [ "$?" = 1 ]   # grep exit 1 == produced no output, not an error
}

# Reads the configuration files and builds the relaxed include/exclude lists.
# relaxed mode is more forgiving about hits, and excludes files generated by various apps.
make_relaxed_filter() {
  local_conf="$(dirname "$0")/lostfiles.conf"
  local local_dir
  local_dir="$(dirname "$local_conf")"

  if [ -f "$local_conf" ] && is_trusted "$local_conf" && is_trusted "$local_dir"; then
    read_config "$local_conf"
  else
    if [ -f "$local_conf" ]; then
      echoerr "warning: ignoring untrusted $local_conf (must be owned by root or \$SUDO_UID, and not group/other writable, as must its directory); falling back to /etc/lostfiles.conf"
    fi

    read_config "/etc/lostfiles.conf"

    # read drop-in configuration overrides from /etc/lostfiles.d/*.conf
    if [ -d /etc/lostfiles.d ]; then
      for f in /etc/lostfiles.d/*.conf; do
        [ -f "$f" ] && read_config "$f"
      done
    fi
  fi

  # exclude the in-use module directory for each installed kernel; these hold
  # generated files (modules.dep, dkms builds) that are not owned by a package
  for kernel in /usr/lib/modules/*/vmlinuz; do
    [ -e "$kernel" ] || continue
    version="${kernel%/vmlinuz}"
    version="${version##*/}"
    [ -n "$version" ] && exclude_list+=("/usr/lib/modules/$version")
  done

  search_paths=("${include_list[@]}")

  # drop symbolic links under /etc/systemd (see function above)
  postfilter="drop_systemd_symlinks"
}

# Do not exclude anything in strict mode, just add the default Arch paths
make_strict_filter() {
  search_paths=(/boot /efi /etc /opt /srv /usr /var)
}

$make_filter

# Lists every file under the configured search paths, honoring the excludes.
#
# fd matches --exclude globs gitignore-style, i.e. relative to the search path
# rather than as absolute paths, so we run fd once per search root and anchor
# each relevant exclude to that root by stripping its prefix (keeping the
# leading slash). An exclude equal to a whole root drops that root entirely.
# Roots left with no excludes are batched into a single trailing fd call
# (fd accepts multiple search paths), since strict mode has no excludes at all.
list_files() {
  local root e rel skip_root
  local -a batch_roots=()
  for root in "${search_paths[@]}"; do
    root="${root%/}"   # tolerate a trailing slash on the configured root
    [ -e "$root" ] || continue
    fd_excludes=()
    skip_root=
    for e in "${exclude_list[@]}"; do
      if [ "$e" = "$root" ]; then
        skip_root=1
        break
      fi
      case "$e" in
        "$root"/*)
          rel="${e#"$root"}"   # strip root prefix, keep leading slash to anchor
          fd_excludes+=(--exclude "$rel")
          ;;
      esac
    done
    [ -n "$skip_root" ] && continue
    if [ "${#fd_excludes[@]}" -eq 0 ]; then
      batch_roots+=("$root")
    else
      fd --absolute-path --hidden --no-ignore --no-global-ignore-file --print0 \
        "${fd_excludes[@]}" '' "$root" 2>/dev/null || :
    fi
  done
  if [ "${#batch_roots[@]}" -gt 0 ]; then
    fd --absolute-path --hidden --no-ignore --no-global-ignore-file --print0 \
      '' "${batch_roots[@]}" 2>/dev/null || :
  fi
}

# A file name containing a literal newline could inject a spoofed line into
# the report (e.g. a fake "not owned by any package" entry). fd's --print0
# output is NUL-delimited so such names survive intact up to here; drop them
# before they reach line-oriented tools downstream, and say so on stderr.
sanitize_names() {
  LC_ALL=C gawk 'BEGIN { RS = "\0" } index($0, "\n") { dropped++; next } { print } END { if (dropped) print "warning: dropped " dropped " file name(s) containing newlines" > "/dev/stderr" }'
}

# fd appends a trailing slash to directories; strip it so the list matches the
# slash-normalized output of `pacman -Qlq` below.
LC_ALL=C comm -13 \
  <(LC_ALL=C pacman -Qlq | sed -e 's|/$||' | LC_ALL=C sort -u) \
  <(list_files | sanitize_names | sed -e 's|/$||' | $postfilter | LC_ALL=C sort -u) | $postprocess

# vim:set ts=2 sw=2 et:
