#!/usr/bin/env bash
# brain-kit maintainer push gate: the installer.
#
# WHAT THIS EXISTS TO PREVENT. The gate is the last thing between this
# repository and a leak of its maintainer's private data, and twice now it
# has been weakened by the question "whose code is this, really":
#
#   - The gate used to run the scanner out of the WORKING TREE. One
#     uncommitted edit to any module it loads walked a leak straight
#     through with exit 0. That is not an attack, it is what an implementer
#     mid-task does by accident.
#   - The fix for that ran the scanner out of the COMMIT BEING PUSHED
#     instead. That was worse: the pushed tip is arbitrary content, so the
#     push chose the code that judged it. A branch carrying its own
#     bin/brain-kit.mjs was accepted with the hook intact, the pattern list
#     correct and no --no-verify, and the code it carried read the
#     environment variable naming the private pattern list.
#
# Both holes are the same mistake, made in opposite directions: the code
# that judges a push was read from somewhere the push itself can reach.
# So it is read from somewhere it cannot. This script copies BOTH halves of
# the gate, the hook and the engine it runs, into a directory under the git
# directory and points core.hooksPath at that copy:
#
#   <git common dir>/brain-kit-gate/pre-push        the hook
#   <git common dir>/brain-kit-gate/engine/         bin, src (with the push
#                                                   enumeration, src/push/),
#                                                   lang, schema, package.json
#   <git common dir>/brain-kit-gate/SNAPSHOT        what was installed, when
#
# WHY THE GIT DIRECTORY. It is the one place with all four properties this
# needs at once. It is never part of any tree, so no commit, branch or tag
# can contain a path that lands in it (git refuses ".git" as a path
# component in a tree, so this is not a convention, it is enforced by the
# object format). A checkout cannot remove it, an orphan branch included.
# `git clean` does not touch it. And it is per clone, so one repository's
# gate cannot be made stale by work done in another. A directory under
# $HOME would satisfy the first three and fail the fourth, sharing one
# snapshot between every clone on the machine.
#
# WHAT THIS DOES NOT CLOSE, and cannot: `git push --no-verify`, and a
# second clone that was never installed into. Both are the maintainer
# deciding not to run the gate, which no client-side gate can overrule.
# Editing the installed copy by hand is the same decision made slowly.
#
# REFRESHING. Re-running this script IS the refresh: it replaces the whole
# directory. Nothing refreshes it automatically, on purpose, because an
# automatic refresh would read the gate back out of the working tree on
# every push and hand the hole back. The snapshot therefore goes stale by
# design, which is why the hook prints which one it ran on every push: a
# ceiling this project cannot remove is one it announces.
#
# The snapshot is taken from the WORKING TREE, not from HEAD, so a change
# to the gate can be tested before it is committed. That is also why
# SNAPSHOT records whether the tree was dirty: a snapshot taken from a
# dirty tree does not correspond exactly to the commit it names, and the
# person reading the line on their next push should be told so rather than
# left to assume.
#
# Portability: bash 3.2 and a POSIX userland, the same bar as the hook.
set -u

# TWO ROOTS, and keeping them apart is the point. The SOURCE is the
# brain-kit checkout this script is part of, found from the script's own
# location, because the gate it installs is the gate that ships beside it.
# The TARGET is the repository the gate is installed INTO, found from the
# current directory. In ordinary use they are the same repository, which is
# exactly why they were easy to conflate; they are separate here so that the
# suite can install a real gate into a scratch repository and exercise the
# real thing instead of an imitation of it.
if ! SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)"; then
  echo "install-gate: could not resolve this script's own directory; refusing to guess where the gate it installs lives." >&2
  exit 1
fi
SOURCE_ROOT="$(dirname "$SELF_DIR")"
INSTALLER_PATH="$SELF_DIR/$(basename "$0")"

# Two different failures used to share one message, and the shared message
# was false for the second of them. `--show-toplevel` fails both when this
# is not a repository at all and when it is a repository with no working
# tree, and saying "not a git repository" to somebody standing in a bare
# clone sends them to look for a problem that is not there. Mirroring a
# vault to a second remote out of a bare clone is an ordinary thing to do,
# so the person who tries it deserves the true reason: this gate runs from
# a working tree (it resolves paths against one, and so does the hook it
# installs), and a bare clone has none.
if ! git rev-parse --git-dir >/dev/null 2>&1; then
  echo "install-gate: the current directory is not inside a git repository (git rev-parse --git-dir failed); nothing to install into." >&2
  exit 1
fi
if ! TARGET_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || [ -z "$TARGET_ROOT" ]; then
  echo "install-gate: this is a git repository with no working tree (a bare or mirror clone), and this gate cannot be installed into one: both it and the hook it installs resolve everything they do against a working tree. Push from an ordinary clone with the gate installed in it instead." >&2
  exit 1
fi

# --git-common-dir rather than --git-dir: in a linked worktree the latter
# points at .git/worktrees/<name>, which is per worktree, while
# core.hooksPath is one setting shared by all of them. Installing into the
# common directory keeps one gate for the repository however many worktrees
# it grows.
if ! GIT_COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null)" || [ -z "$GIT_COMMON_DIR" ]; then
  echo "install-gate: could not locate the git directory (git rev-parse --git-common-dir failed); refusing to guess where to install." >&2
  exit 1
fi
case "$GIT_COMMON_DIR" in
  /*) ;;
  *) GIT_COMMON_DIR="$TARGET_ROOT/$GIT_COMMON_DIR" ;;
esac
# Resolved with `pwd -P` on both sides here and in the hook, so the two
# agree about a path reached through a symlink (macOS /tmp is one).
if ! GIT_COMMON_DIR="$(cd "$GIT_COMMON_DIR" 2>/dev/null && pwd -P)"; then
  echo "install-gate: the git directory reported by git does not exist; refusing to install." >&2
  exit 1
fi

GATE_DIR="$GIT_COMMON_DIR/brain-kit-gate"
STAGING_DIR="$GIT_COMMON_DIR/brain-kit-gate.installing"
SOURCE_HOOK="$SOURCE_ROOT/.githooks/pre-push"

if [ ! -f "$SOURCE_HOOK" ]; then
  echo "install-gate: $SOURCE_HOOK does not exist, so there is no gate to install; run this from a brain-kit checkout." >&2
  exit 1
fi

# The engine is the whole runnable package, not just bin and src:
# src/cli.mjs builds its translator before dispatching any command, which
# reads lang/; kitVersion reads package.json; schema/ travels with them.
# A snapshot of only bin and src refuses every push with a module error.
ENGINE_PATHS="bin src lang schema package.json"
for path in $ENGINE_PATHS; do
  if [ ! -e "$SOURCE_ROOT/$path" ]; then
    echo "install-gate: $SOURCE_ROOT/$path is missing, so the engine snapshot would be incomplete; refusing to install half a gate." >&2
    exit 1
  fi
done

# Built under a staging name and moved into place at the end, so an
# interrupted install never leaves a gate that is half one version and half
# another. The hook checks for a complete install as well, because a move
# is not an atomic operation on every filesystem and because somebody can
# always delete part of the directory afterwards.
rm -rf "$STAGING_DIR" || exit 1
if ! mkdir -p "$STAGING_DIR/engine"; then
  echo "install-gate: could not create $STAGING_DIR; refusing to install." >&2
  exit 1
fi

if ! cp "$SOURCE_HOOK" "$STAGING_DIR/pre-push"; then
  echo "install-gate: could not copy the hook into $STAGING_DIR; refusing to install." >&2
  rm -rf "$STAGING_DIR"
  exit 1
fi
if ! chmod 755 "$STAGING_DIR/pre-push"; then
  echo "install-gate: could not make the installed hook executable; refusing to install." >&2
  rm -rf "$STAGING_DIR"
  exit 1
fi

for path in $ENGINE_PATHS; do
  # `cp -R` rather than `cp -a`: -a is GNU-only, -R is in both userlands.
  if ! cp -R "$SOURCE_ROOT/$path" "$STAGING_DIR/engine/$path"; then
    echo "install-gate: could not copy $path into the engine snapshot; refusing to install." >&2
    rm -rf "$STAGING_DIR"
    exit 1
  fi
done

# THE SNAPSHOT MUST BE A COPY, AND `cp -R` DOES NOT GUARANTEE THAT.
#
# Neither -R nor -r dereferences a symbolic link: a link in the source tree
# is installed as a link, and the file the gate then executes is whatever
# that link points at, which stays writable after the install and lives
# wherever it likes. A reviewer pointed src/leak.mjs at a file outside the
# repository, replaced the target with a module keeping every export and
# blunting only the scan, and pushed a leak with exit 0 and no re-install.
# That is hole (i) restored, through a file this installer believed it had
# copied, and no test in the suite can see it: nothing plants a symlink and
# the harness's own copy does not dereference either.
#
# `cp -RL` would close it by dereferencing, and it is rejected on purpose.
# Dereferencing makes the wrong thing work: this project's engine has no
# symbolic links in it, so one appearing is not a shape to accommodate, it
# is a sign that the tree is not what the installer thinks it is. Refusing
# says that where copying would say nothing, and the person reading it
# learns which path to look at.
#
# Counted rather than parsed: a path containing a newline would split into
# two lines and inflate the count, which still refuses. There is no shape
# of output here that can be read as "no links" when there were links.
SYMLINKS_FOUND="$(find "$STAGING_DIR/engine" -type l 2>/dev/null | wc -l | tr -d ' ')"
if [ -z "$SYMLINKS_FOUND" ] || [ "$SYMLINKS_FOUND" != "0" ]; then
  echo "install-gate: the engine snapshot would contain a symbolic link, so it would not be a self-contained copy: the gate would execute whatever the link points at, which stays writable after this install. This project's engine has no symbolic links in it. The offending path(s), under $SOURCE_ROOT:" >&2
  find "$STAGING_DIR/engine" -type l 2>/dev/null | sed "s|^$STAGING_DIR/engine/|  |" >&2
  echo "install-gate: refusing to install a gate that reads its own code from outside itself." >&2
  rm -rf "$STAGING_DIR"
  exit 1
fi
if [ -L "$SOURCE_HOOK" ]; then
  echo "install-gate: $SOURCE_HOOK is a symbolic link, so the hook this installs would be decided by whatever it points at; refusing to install a gate whose source is not the file it appears to be." >&2
  rm -rf "$STAGING_DIR"
  exit 1
fi

# Checked here and not only trusted: the files the hook will refuse
# without. Finding out at install time beats finding out on a push.
# src/push/records.sh is the enumeration push-gate runs out of this
# snapshot (the half of the gate that decides what is scanned at all); it
# arrives with src/ like everything else, and is named here because a
# snapshot without it is a gate that cannot say what a push contains.
for path in bin/brain-kit.mjs src/commands/scan-blobs.mjs src/commands/push-gate.mjs src/push/records.sh; do
  if [ ! -f "$STAGING_DIR/engine/$path" ]; then
    echo "install-gate: the engine snapshot has no $path in it; refusing to install a gate that cannot run." >&2
    rm -rf "$STAGING_DIR"
    exit 1
  fi
done

# The FULL object name, not the abbreviated one. The hook compares this
# against the HEAD of the repository being pushed to decide whether the
# snapshot is stale (see its own (o) below), and an abbreviation is not a
# value two git invocations are obliged to agree about: the length git
# picks grows with the object count, so a short name written today and a
# short name read tomorrow can differ without anything being stale. A
# comparison that has to guess how much of two strings to look at is the
# prefix-match mistake this gate has already had to refuse once.
SOURCE_COMMIT="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null)"
if [ -z "$SOURCE_COMMIT" ]; then
  SOURCE_COMMIT="no commit yet"
fi
if [ -n "$(git -C "$SOURCE_ROOT" status --porcelain 2>/dev/null)" ]; then
  TREE_STATE="working tree dirty, so this snapshot is not exactly that commit"
else
  TREE_STATE="working tree clean"
fi
# Dates a person reads are DD/MM/YYYY in this project.
INSTALLED_AT="$(date '+%d/%m/%Y %H:%M')"

# The command that re-installs this gate, recorded rather than
# reconstructed, and recorded RELATIVE to the repository it is installed
# into. It used to be the absolute path, which on the maintainer's own
# machine is a home directory with a person's name in it, and the hook
# prints this string on stderr, the same stream the findings use, so it
# travelled into every terminal, log and pasted transcript a push appeared
# in. On a project whose whole subject is not disclosing that sort of
# thing, the gate must not be the thing that discloses it.
#
# In the ordinary case the installer lives inside the repository it
# protects and the relative name is the exact command, runnable from the
# repository root. When it does not, there is no relative name that would
# be correct, and inventing one would put the same home directory back in
# a different shape, so the generic form is recorded: it is what every
# other message in the hook already says, and it is true everywhere.
case "$INSTALLER_PATH" in
  "$TARGET_ROOT"/*) INSTALLER_RECORD="${INSTALLER_PATH#"$TARGET_ROOT"/}" ;;
  *) INSTALLER_RECORD=".githooks/install-gate (run it from your brain-kit checkout)" ;;
esac
if ! printf '%s\n' "$INSTALLER_RECORD" > "$STAGING_DIR/INSTALLER"; then
  echo "install-gate: could not record this script's own path in the gate; refusing to install a gate that cannot say how to repair itself." >&2
  rm -rf "$STAGING_DIR"
  exit 1
fi

# Written LAST, and read by the hook as the marker of a COMPLETE install:
# every other file is in place by the time this line exists.
if ! printf 'installed %s from commit %s (%s)\n' "$INSTALLED_AT" "$SOURCE_COMMIT" "$TREE_STATE" > "$STAGING_DIR/SNAPSHOT"; then
  echo "install-gate: could not write the snapshot stamp; refusing to install a gate that cannot say what it is." >&2
  rm -rf "$STAGING_DIR"
  exit 1
fi

# NEVER DELETE THE LIVE GATE BEFORE THE REPLACEMENT IS IN PLACE.
#
# This used to be `rm -rf "$GATE_DIR"` and then a move, and the failure
# mode of a refresh was therefore NO GATE AT ALL, silently: between those
# two statements core.hooksPath names a directory that does not exist, and
# git skips a missing hook without a word. A Ctrl-C, a crash, a full disk
# or a move that fails for any reason left the repository looking gated
# and pushing everything unexamined with exit 0. A reviewer proved it by
# deleting the directory and pushing a leak straight to a bare remote.
#
# That inverts the design's own reasoning. The half-done install the
# staging directory guards against (hook present, engine missing) refuses
# loudly and names the fix, and the hook has two tests for it. This one
# cannot be detected by the hook, because the hook is what is missing,
# which makes it the worse of the two and the one to order the steps
# around. The old gate moves aside, the new one takes its place, and the
# old one is removed last; if the second rename fails the old one goes
# back. Both renames are within one directory, so neither needs space and
# neither can half-happen, and the only instant without a gate is between
# them.
PREVIOUS_DIR="$GIT_COMMON_DIR/brain-kit-gate.previous"
rm -rf "$PREVIOUS_DIR" || exit 1
HAD_PREVIOUS=0
if [ -e "$GATE_DIR" ]; then
  if ! mv "$GATE_DIR" "$PREVIOUS_DIR"; then
    echo "install-gate: could not move the existing gate aside; refusing rather than deleting a working gate to make room for one that may not arrive." >&2
    rm -rf "$STAGING_DIR"
    exit 1
  fi
  HAD_PREVIOUS=1
fi
if ! mv "$STAGING_DIR" "$GATE_DIR"; then
  echo "install-gate: could not move the staged gate into $GATE_DIR; refusing to leave a partial install." >&2
  if [ "$HAD_PREVIOUS" -eq 1 ]; then
    if mv "$PREVIOUS_DIR" "$GATE_DIR"; then
      echo "install-gate: the gate that was already installed has been put back, so this repository is still gated." >&2
    else
      echo "install-gate: the gate that was already installed could NOT be put back, so this repository has core.hooksPath set and no gate behind it, and git skips a missing hook in silence. Restore it by hand (mv $PREVIOUS_DIR $GATE_DIR) or re-run this script." >&2
    fi
  fi
  rm -rf "$STAGING_DIR"
  exit 1
fi
rm -rf "$PREVIOUS_DIR"

if ! git -C "$TARGET_ROOT" config core.hooksPath "$GATE_DIR"; then
  echo "install-gate: the gate is installed at $GATE_DIR but core.hooksPath could not be set, so git will not run it. Set it by hand: git config core.hooksPath $GATE_DIR" >&2
  exit 1
fi

echo "install-gate: gate installed at $GATE_DIR"
echo "install-gate: $(cat "$GATE_DIR/SNAPSHOT")"
echo "install-gate: core.hooksPath now points at it. Re-run this script to refresh the snapshot."
exit 0
