#!/bin/bash

OPTS_DIR=/usr/share/i360-php-opts
LIVE=${OPTS_DIR}/phpimunity.compiled.rules
LOCKFILE=${OPTS_DIR}/i360storagemkcustom.lock
# Referenced from rules.yaml as `danger_files: 89100`; staged below.
DANGER_LIST=suspicious_files_list.89100

if [ -e ${LOCKFILE} ] && kill -0 `cat ${LOCKFILE}`; then
    echo "already running" 1>&2
    exit 1
fi

# Staging directory, next to the live one so the publish below is a rename
# within the same filesystem. Cleaned up on every exit path.
STAGE=
# Holds the previous rules for the two-rename window in which the live path
# does not exist, so an interrupted publish can be undone.
BACKUP=

cleanup() {
  # Killed between the two publish renames: the live path is gone and BACKUP is
  # the only copy of the previous rules. Put them back before releasing the
  # lock, so the next run cannot start from an empty rule set. Never remove
  # BACKUP while it is that only copy.
  if [ -n "${BACKUP}" ] && [ -d "${BACKUP}" ]; then
    if [ -e "${LIVE}" ]; then
      rm -rf "${BACKUP}"
    elif mv -T "${BACKUP}" "${LIVE}"; then
      echo "restored the previous rules after an interrupted publish" 1>&2
    else
      echo "cannot restore the previous rules, they are left in ${BACKUP}" 1>&2
    fi
  fi
  [ -n "${STAGE}" ] && rm -rf "${STAGE}"
  rm -f "${LOCKFILE}"
  exit
}
trap cleanup HUP INT TERM EXIT

echo $$ > ${LOCKFILE}
chmod 600 ${LOCKFILE}

# SIGKILL, a panic or a power cut inside the publish window leaves no live
# directory and the rules stranded in a ${LIVE}.old.<pid> backup — the trap
# above never ran. We hold the lock, so nothing else is publishing and every
# such directory is an orphan: restore the newest, then drop them all.
newest_backup=
for orphan in "${LIVE}".old.*; do
  [ -d "${orphan}" ] || continue
  if [ -z "${newest_backup}" ] || [ "${orphan}" -nt "${newest_backup}" ]; then
    newest_backup="${orphan}"
  fi
done
if [ -n "${newest_backup}" ] && [ ! -e "${LIVE}" ]; then
  echo "restoring the rules left by an interrupted publish (${newest_backup})" 1>&2
  mv -T "${newest_backup}" "${LIVE}" || exit 1
fi
for orphan in "${LIVE}".old.*; do
  [ -d "${orphan}" ] && rm -rf "${orphan}"
done

if [ ! -e "${LIVE}" ]; then
  mkdir "${LIVE}"
  chmod 755 "${LIVE}"
fi

if [ ! -e "${LIVE}" ]; then
  echo "Can't find ${LIVE} directory" 1>&2
  exit 1
fi

if [ -z "$1" -o ! -e "$1" ]; then
  echo "Can't find $1 file" 1>&2
  exit 1
fi

rules_file=$(readlink -f "$1")

STAGE=$(mktemp -d "${OPTS_DIR}/phpimunity.compiled.rules.new.XXXXXX") || exit 1
chmod 755 "${STAGE}"

# The compiler slurps the danger lists relative to its own cwd and bakes them
# into the compiled output, so they have to be in the staging directory before
# it runs.
#
# A list it cannot read is invisible: add_susp_file_to_list() skips a file that
# fails access(), read_rev_ordered_set_from_list() returns on an empty list and
# is_list is set anyway, so the compiler exits 0 having built the
# `danger_files: 89100` rules with an empty set — they match nothing and still
# pass the validation below. In-place compiles never hit that because the list
# stayed in the live directory between runs; a fresh staging directory has to
# carry it over. Copy through a .part name so an interrupted cp cannot leave a
# truncated list behind, and fall back to the published copy when the source is
# unavailable.
# TODO: move to i360 agent
# Every match is copied over the same .part, so the last one wins — as it did
# with the old `find -exec cp`. staged_list therefore tracks only the last copy:
# a later failure that truncates .part must not be promoted because an earlier
# one happened to succeed.
staged_list=
while IFS= read -r -d '' src; do
  if cp -f "${src}" "${STAGE}/${DANGER_LIST}.part"; then
    staged_list=1
  else
    staged_list=
  fi
done < <(find /var/imunify360/files/php-immunity/ -name "${DANGER_LIST}" -print0 2>/dev/null)

if [ -n "${staged_list}" ]; then
  mv -f "${STAGE}/${DANGER_LIST}.part" "${STAGE}/${DANGER_LIST}" || exit 1
else
  rm -f "${STAGE}/${DANGER_LIST}.part"
  if [ -f "${LIVE}/${DANGER_LIST}" ]; then
    echo "cannot stage ${DANGER_LIST}, reusing the published copy" 1>&2
    cp -f "${LIVE}/${DANGER_LIST}" "${STAGE}/${DANGER_LIST}" || exit 1
  else
    # Nothing was ever published either: a host whose agent has not delivered
    # the list yet. Compiling without it is what used to happen there, so this
    # is a warning and not a failure.
    echo "no ${DANGER_LIST} available, danger_files rules will be compiled empty" 1>&2
  fi
fi

# Compile into the staging directory. Until this succeeds the live rules are
# untouched: a failure or a kill mid-compile used to leave the live directory
# emptied by the rm below and half-repopulated.
pushd "${STAGE}" &>/dev/null
i360-storage.immunity mksbins "${rules_file}"
rc=$?
popd &>/dev/null

if [ ${rc} -ne 0 ]; then
  echo "compilation failed (${rc}), keeping the current rules" 1>&2
  exit ${rc}
fi

# Do not trust the exit code alone: the compiler exits 0 on "No host scripts
# rules find" (make-storage.c), and an empty yaml would otherwise publish an
# empty directory over a working rule set. Publish only a complete-looking set.
#
# find -print -quit rather than a glob: the shell would expand one argument per
# artifact and blow past ARG_MAX at ~26k of them, so a large but perfectly good
# compile would fail validation and never publish — the very case this change
# exists to unlock. -quit also stops at the first match instead of listing all.
if [ ! -s "${STAGE}/autorules.slist" ] ||
   [ -z "$(find "${STAGE}" -maxdepth 1 -name 'autorules.*.srule' -print -quit)" ]; then
  echo "compilation produced no rules, keeping the current ones" 1>&2
  exit 1
fi

# Publish by swapping the directories. Two renames, so the live path is absent
# for microseconds rather than for the whole compile; a worker that looks in
# that window simply finds no rules and reloads on the next request. Dying
# inside the window is undone by the trap, or by the orphan recovery above when
# the signal was one no trap can catch.
# Deliberately not a glob: with one artifact pair per script a rule set of a few
# tens of thousands of scripts blows past ARG_MAX (~2 MiB) in a single `rm -f
# dir/*.srule`.
BACKUP="${LIVE}.old.$$"
rm -rf "${BACKUP}"
if ! mv -T "${LIVE}" "${BACKUP}"; then
  echo "cannot move the current rules aside, keeping them" 1>&2
  BACKUP=
  exit 1
fi
if ! mv -T "${STAGE}" "${LIVE}"; then
  # cleanup moves BACKUP back and drops the staging directory.
  echo "cannot publish the new rules, restoring the previous ones" 1>&2
  exit 1
fi
STAGE=

exit 0
