# Hard rule 9's guard, in one place. Sourced, never run:
#   . "$(dirname "$0")/../tools/flea-sandbox-guard"
# Needs realpath from coreutils, which is the tree's only test-side binary dependency. An absent or
# failing one fails closed: sandbox_canon returns non-zero and every caller either refuses on that or
# treats it as a refusal, including sandbox_forbidden, which must never read it as "not under".
# A lane agent test-driving file operations against real paths under $HOME emptied /home/gm on
# 2026-09-01, and there is no @home snapshot, so every destructive path in this tree comes through
# here. Each check is code that runs before the delete, not a promise in a report.

# Fixtures live outside $HOME: ~/tmp is one unset variable away from the home directory.
FIXTURE_ROOT=${FLEA_FIXTURE_ROOT:-/home/flea-sandbox}
# The marker is what stands between a directory and rm -rf: no marker, no delete.
SANDBOX_MARKER=.flea-test-sandbox

sandbox_refuse() {
  printf 'REFUSED: %s\n' "$*" >&2
  exit 1
}

# set -u catches an unset HOME and not an empty one, and every forbidden root below is written from
# it: with HOME="" the $HOME entry silently contributes nothing and /.cache passes as a cache path.
# Checked at call time and not only here, because a caller can empty HOME after sourcing this.
sandbox_home_ok() {
  [ -n "${HOME:-}" ] || sandbox_refuse "HOME is empty or unset, so no path this guard checks can be trusted"
}
sandbox_home_ok

# One canonical form for every path this guard touches, checks and actions alike. realpath -m
# collapses a trailing slash, an interior "//", a "." and a ".." and resolves symlinks, in one call
# and without requiring the path to exist yet, which a first run always needs. Stripping trailing
# slashes by hand only ever closed the instance in front of us: an interior "//" in HOME emptied the
# forbidden list exactly the way a trailing one did.
# corner: realpath comes from coreutils and is present on this box; an absent one fails closed here.
sandbox_canon() {
  local p
  p=$(realpath -m -- "$1" 2>/dev/null) || return 1
  [ -n "$p" ] || return 1
  printf '%s' "$p"
}

# Non-empty, absolute, no parent traversal, and at least two components deep, so an unset variable
# can never resolve this to /, to a top-level directory, or to somewhere above the one named.
sandbox_plausible() {
  local p="$1"
  [ -n "$p" ] || return 1
  case "$p" in
    /*) ;;
    *) return 1 ;;
  esac
  case "$p" in
    */../*|*/..|../*|..) return 1 ;;
  esac
  case "$p" in
    /*/*) ;;
    *) return 1 ;;
  esac
  return 0
}

# True when child is under parent, comparing whole components: /home/gmother is not under /home/gm.
# Both sides are normalised first: a trailing slash on the parent silently matched nothing, which
# emptied the whole forbidden-roots list whenever HOME carried one.
sandbox_under() {
  local child parent
  # Both sides refuse rather than returning 1 on a resolve failure: a caller reads 1 as "not under",
  # and reading "could not resolve" as "not forbidden" is how a root drops out of the guard's list.
  child=$(sandbox_canon "$1") || sandbox_refuse "cannot resolve the path being checked: '$1'"
  parent=$(sandbox_canon "$2") || sandbox_refuse "cannot resolve the root being checked against: '$2'"
  [ -n "$parent" ] || sandbox_refuse "the root being checked against is empty"
  case "$child/" in
    "$parent"/*) return 0 ;;
  esac
  return 1
}

# The roots hard rule 9 names, plus the system trees. $HOME covers ~/Work, ~/.config, ~/.local,
# ~/.cache and ~/tmp in one line, because every one of them is inside it.
sandbox_forbidden() {
  local p="$1" root canon
  sandbox_home_ok
  for root in "$HOME" /usr /etc /var /boot /root /srv /opt; do
    # A root that cannot be canonicalised must not silently drop out of the list: sandbox_under
    # answers "not under" for both "outside it" and "could not resolve it", and reading the second
    # as the first is how a symlink loop under $HOME would remove $HOME from this list entirely.
    canon=$(sandbox_canon "$root") || sandbox_refuse "cannot resolve the forbidden root '$root'"
    sandbox_under "$p" "$canon" && return 0
    [ "$p" = "$canon" ] && return 0
    :
  done
  return 1
}

# The fixture root itself, checked once before anything writes under it. The root is
# environment-overridable by design, which is exactly why it is checked rather than trusted.
# SANDBOX_ROOT is the canonical fixture root, published so no caller re-derives it.
sandbox_root_ok() {
  sandbox_home_ok
  # Canonicalise BEFORE validating: /home/ satisfies the depth test's second * with the empty string
  # and reads as two components deep, so a raw check passes a root the canonical one refuses.
  # The raw form first, for the same reason: a root written with a .. is a root nobody can audit.
  sandbox_plausible "$FIXTURE_ROOT" \
    || sandbox_refuse "FLEA_FIXTURE_ROOT is not an absolute path two components deep: '$FIXTURE_ROOT'"
  SANDBOX_ROOT=$(sandbox_canon "$FIXTURE_ROOT") \
    || sandbox_refuse "FLEA_FIXTURE_ROOT cannot be resolved: '$FIXTURE_ROOT'"
  sandbox_plausible "$SANDBOX_ROOT" \
    || sandbox_refuse "FLEA_FIXTURE_ROOT is not an absolute path two components deep: '$FIXTURE_ROOT'"
  sandbox_forbidden "$SANDBOX_ROOT" \
    && sandbox_refuse "FLEA_FIXTURE_ROOT is inside a directory hard rule 9 forbids: '$FIXTURE_ROOT'"
  return 0
}

# Every path a suite creates or deletes passes here. Under the fixture root, never the root itself,
# so no run can take out another run's sandbox alongside its own.
# Validates one path and publishes its canonical form as SANDBOX_PATH, which is what every action
# below acts on: a check on the canonical form and an action on the raw one is not the same check.
sandbox_require() {
  sandbox_root_ok
  # The RAW form is what carries a .. or a relative prefix, and realpath resolves both away, so
  # checking only the canonical form left two of sandbox_plausible's three arms unable to fire.
  sandbox_plausible "$1" || sandbox_refuse "not an absolute path two components deep: '$1'"
  SANDBOX_PATH=$(sandbox_canon "$1") || sandbox_refuse "cannot be resolved: '$1'"
  sandbox_plausible "$SANDBOX_PATH" || sandbox_refuse "does not resolve to a usable path: '$1'"
  # Containment in a root that sandbox_root_ok has already cleared is what keeps it out of every
  # directory hard rule 9 forbids, so there is no second per-path check to go stale against it. The
  # canonical form is also what makes this a symlink check: a path resolving out of the root fails.
  sandbox_under "$SANDBOX_PATH" "$SANDBOX_ROOT" \
    || sandbox_refuse "resolves to '$SANDBOX_PATH', which is outside the fixture root $SANDBOX_ROOT"
  [ "$SANDBOX_PATH" = "$SANDBOX_ROOT" ] \
    && sandbox_refuse "is the fixture root itself, which is never a target: '$1'"
  return 0
}

# Test-owned means the path carries the marker itself, or sits directly inside something that does.
sandbox_owned() {
  local p="$1"
  [ -f "$p/$SANDBOX_MARKER" ] && return 0
  [ -f "${p%/*}/$SANDBOX_MARKER" ] && return 0
  return 1
}

# Delete a sandbox or a scratch inside one. A directory that exists and is owned by neither is
# somebody else's and is refused; one that does not exist yet is fine, a first run has nothing to remove.
# Every action takes its own copy of the checked path the instant sandbox_require returns, so a
# nested call cannot clobber the global between the check and the act. The copy is a plain assignment
# and never a command substitution: sandbox_refuse is an exit, and an exit inside $( ) ends only the
# subshell, so a refusal there prints and the caller carries on with an empty path. None of the
# scripts sourcing this set -e, so nothing else would have stopped them.
sandbox_take() {
  [ -n "${SANDBOX_PATH:-}" ] || sandbox_refuse "the checked path is empty, which is never a target"
  SANDBOX_TAKEN=$SANDBOX_PATH
}

sandbox_remove() {
  local p
  sandbox_require "$1"
  sandbox_take
  p=$SANDBOX_TAKEN
  if [ -e "$p" ]; then
    sandbox_owned "$p" \
      || sandbox_refuse "$p is not marked and is not inside a marked sandbox, so this will not delete it"
    rm -rf "$p" || sandbox_refuse "could not remove $p"
  fi
  return 0
}

# Remove whatever was there and build a fresh sandbox carrying its own marker.
sandbox_make() {
  local p
  sandbox_remove "$1"
  # Re-checked rather than reusing the copy sandbox_remove took, because that call is nested.
  sandbox_require "$1"
  sandbox_take
  p=$SANDBOX_TAKEN
  mkdir -p "$p" || sandbox_refuse "could not create $p"
  : > "$p/$SANDBOX_MARKER" || sandbox_refuse "could not mark $p"
  return 0
}

# A working directory inside a sandbox that is already marked. The marker stays on the sandbox root,
# so nothing the guard writes appears in a listing the test is about to assert row counts on.
sandbox_scratch() {
  local p
  sandbox_require "$1"
  sandbox_take
  p=$SANDBOX_TAKEN
  # The canonical form, so a trailing slash cannot make ${p%/*} return the path itself and collapse
  # this onto the sandbox's own marker, which would delete the whole sandbox and recreate it unmarked.
  [ -f "${p%/*}/$SANDBOX_MARKER" ] \
    || sandbox_refuse "$p is not directly inside a marked sandbox, so this will not delete it"
  rm -rf "$p" || sandbox_refuse "could not remove $p"
  mkdir -p "$p" || sandbox_refuse "could not create $p"
  return 0
}

# The thumbnail cache is not a sandbox and never becomes one: it is the operator's own directory,
# so this only asserts a caller's path is a real one under it before the caller touches it.
sandbox_cache_require() {
  local p cache
  sandbox_home_ok
  # The raw form first, the same order the two siblings use: realpath resolves a .. away, so checking
  # only the canonical form leaves the caller's own defects unable to fire. This is the gate above
  # the bench's rm -rf of the thumbnail cache.
  sandbox_plausible "$1" || sandbox_refuse "not an absolute path two components deep: '$1'"
  p=$(sandbox_canon "$1") || sandbox_refuse "cannot be resolved: '$1'"
  cache=$(sandbox_canon "$HOME/.cache") || sandbox_refuse "cannot resolve the cache root"
  sandbox_plausible "$p" || sandbox_refuse "does not resolve to a usable path: '$1'"
  sandbox_under "$p" "$cache" || sandbox_refuse "outside $cache: '$1'"
  [ "$p" = "$cache" ] && sandbox_refuse "is the cache root itself, which is never a target: '$1'"
  return 0
}
