#!/usr/bin/env bash
# abysscrypt-test — automated mount / write / verify / teardown test harness
#
# Exercises non-interactive mode across random combinations of cipher phrase,
# hash phrase, passphrase(s), levels, and optional multipass breakpoints.
# Each cycle: create container → mount (mkfs) → write sentinel → unmount →
# remount (no-mkfs) → verify sentinel → unmount. Reports PASS/FAIL per cycle.
#
# USAGE
#   ./abysscrypt-test [OPTIONS]
#
# OPTIONS
#   --cycles N          How many independent test cycles to run. Each cycle
#                       gets its own freshly-randomised container, cipher
#                       phrase, hash phrase, passphrase(s), level count, and
#                       (optionally) multipass breakpoints.
#                       Default: 5
#
#   --max-levels N      Upper bound for the randomly-chosen level count per
#                       cycle. Ignored when --fixed-levels is set.
#                       Default: 10
#
#   --fixed-levels N    Use exactly N encryption levels every cycle instead of
#                       picking a random count in [1, --max-levels].
#
#   --no-multipass      Never generate multipass breakpoints. All levels in
#                       every cycle will share a single passphrase group.
#
#   --container-mb M    Size of the throwaway container image created for each
#                       cycle, in MiB. Larger values slow the test but give
#                       mkfs more room.
#                       Default: 64
#
#   --base-dir DIR      Directory under which all container images and mount
#                       points are created. Must resolve (after symlink
#                       expansion) to a path inside \$HOME or \$PWD. The
#                       directory is created if it does not exist. Nothing
#                       outside this directory is ever written or deleted.
#                       Default: \$PWD/.abysscrypt-test
#
#   --sudo              Invoke abysscrypt via sudo. Use this when the calling
#                       user has a NOPASSWD sudoers rule for abysscrypt, as
#                       described in the README. Without this flag the binary
#                       is called directly (suitable for root shells inside
#                       VMs / containers, or when ambient capabilities are
#                       present).
#                       Default: off (direct invocation)
#
#   --abysscrypt PATH   Path to the abysscrypt binary.
#                       Default: ./abysscrypt (next to this script)
#
#   -v, --verbose       Print the full output of every abysscrypt invocation
#                       instead of only lines containing "error:" or "warning:".
#
#   -h, --help          Print this help and exit.
#
# EXAMPLES
#   # Quick smoke-test, 3 cycles, direct invocation (run as root or with caps):
#   ./abysscrypt-test --cycles 3 --fixed-levels 2
#
#   # Full random test via sudo (sudoers rule required):
#   ./abysscrypt-test --sudo --cycles 10 --max-levels 8
#
#   # Fixed 5 levels, no multipass, verbose, custom binary:
#   ./abysscrypt-test --fixed-levels 5 --no-multipass -v \
#       --abysscrypt /usr/local/bin/abysscrypt
#
# SAFETY GUARANTEES
#   - No sudo by default; --sudo is an explicit opt-in.
#   - Refuses to run as root when --sudo is active (avoids sudo-inside-root).
#   - --base-dir is validated to live inside \$HOME or \$PWD; system paths
#     (/etc, /usr, /var, ...) are rejected even if HOME points there.
#   - Refuses a --base-dir that is a symlink, world-writable without the
#     sticky bit, or not owned by the current user.
#   - Every cycle's container and mount dir are created with mktemp (O_EXCL);
#     nothing is ever overwritten.
#   - Cleanup only deletes paths this script created, after re-validating each
#     one is still inside the base dir (guards against symlink swaps).
#   - Secrets come from /dev/urandom, are written to mode-0600 files in
#     the base dir, and passed to abysscrypt as file: source specs. File
#     paths appear in argv; secret bytes never do. Files are overwritten
#     and unlinked at the end of each cycle.

set -euo pipefail

# ── defaults ──────────────────────────────────────────────────────────────────
ABYSSCRYPT="${ABYSSCRYPT:-$(dirname "$0")/abysscrypt}"
CYCLES=5
MAX_LEVELS=10
FIXED_LEVELS=0
ENABLE_MULTIPASS=1
CONTAINER_MB=64
VERBOSE=0
BASE_DIR_RAW=""
USE_SUDO=0
STATIC_CONTAINER=""           # path — reuse the same container every cycle
declare -a POOL_ARGS=()        # --with-X / --without-X / --cipher / --hash pass-through

# ── runtime state ─────────────────────────────────────────────────────────────
declare -a CREATED_CONTAINERS=()
declare -a CREATED_MOUNTDIRS=()
declare -a ACTIVE_MOUNTDIRS=()
declare -a CREATED_SECRETS=()
BASE_DIR=""
PASS=0
FAIL=0

# ── helpers ───────────────────────────────────────────────────────────────────
die() { echo "abysscrypt-test: $*" >&2; exit 1; }
log() { [[ "$VERBOSE" -eq 1 ]] && echo "  $*"; return 0; }
err() { echo "  ERROR: $*" >&2; }

# Invoke abysscrypt with or without sudo depending on --sudo flag.
# Secrets are passed via file: source specs (not env:), so we do not need
# to forward environment variables through sudo. This keeps the sudoers
# rule minimal: NOPASSWD: /usr/local/bin/abysscrypt is sufficient — no
# SETENV directive, no wrapping in `sudo env`.
run_abyss() {
    if [[ "$USE_SUDO" -eq 1 ]]; then
        sudo -n "$ABYSSCRYPT" "$@"
    else
        "$ABYSSCRYPT" "$@"
    fi
}

usage() {
    cat <<'EOF'
abysscrypt-test — automated mount / write / verify / teardown test harness

USAGE
  ./abysscrypt-test [OPTIONS]

OPTIONS
  --cycles N          Number of independent test cycles to run.
                      Each cycle gets a fresh container and fresh secrets.
                      Default: 5

  --max-levels N      Upper bound for the random level count per cycle.
                      Ignored when --fixed-levels is set.
                      Default: 10

  --fixed-levels N    Use exactly N levels every cycle instead of random.

  --no-multipass      Never generate multipass breakpoints; one passphrase
                      group per cycle.

  --container-mb M    Container size per cycle, in MiB.
                      Default: 64

  --base-dir DIR      Where containers and mount points live. Must resolve
                      inside $HOME or $PWD; created if absent. Nothing
                      outside this directory is ever written or deleted.
                      Default: $PWD/.abysscrypt-test

  --static-container PATH
                      Reuse this existing container file for every cycle
                      instead of creating a fresh one. Skips the dd fill
                      step — useful when the container is large or when
                      you want to repeatedly test the same image.
                      The file must already exist and must be inside
                      $HOME or $PWD. It is never deleted by this script.

  --with-camellia     Add Camellia to the cipher pool (pass-through to
  --with-blowfish     abysscrypt). Kernel module must be loaded.
  --with-cast6
  --with-anubis

  --without-aes       Remove a default cipher/hash from the pool.
  --without-serpent
  --without-twofish
  --without-sha256
  --without-sha512
  --without-whirlpool
  --without-ripemd160

  --cipher SPEC       Add a custom cipher to the pool, e.g.
                      "aes-xts-plain64:256". Clears the default pool on
                      first use (same behaviour as abysscrypt itself).
                      Repeat to add multiple ciphers.

  --hash ALG          Add a custom hash to the pool, e.g. "sha256".
                      Clears the default pool on first use. Repeat for
                      multiple hashes.

  --sudo              Invoke abysscrypt via sudo -n. Secrets are passed
                      via file: source specs (not env:), so the minimal
                      NOPASSWD sudoers rule from the README works as-is
                      — no SETENV, no `sudo env` wrapper.
                      Default: off (direct invocation)

  --abysscrypt PATH   Path to the abysscrypt binary.
                      Default: ./abysscrypt (next to this script)

  -v, --verbose       Print full abysscrypt output instead of filtered.

  -h, --help          Show this help and exit.

EXAMPLES
  ./abysscrypt-test --cycles 3 --fixed-levels 2
  ./abysscrypt-test --sudo --cycles 10 --max-levels 8
  ./abysscrypt-test --fixed-levels 5 --no-multipass -v

SAFETY
  - No sudo by default; --sudo is explicit opt-in.
  - --base-dir is validated to live inside $HOME or $PWD; system paths
    rejected even if HOME points there. Symlinked, world-writable, or
    non-owned base dirs are rejected.
  - Each cycle's container and mount dir are created with mktemp (O_EXCL);
    nothing is ever overwritten.
  - Cleanup only touches paths created by this script, re-validated to
    still be inside the base dir.
  - Secrets come from /dev/urandom, are written to mode-0600 files in
    the base dir, and passed as file: source specs. File paths appear
    in argv; secret bytes never do. Files are wiped + unlinked per cycle.
EOF
}

# Resolve a path to its canonical absolute form (follows symlinks; leaf need
# not exist). Returns empty string on failure.
canon() { readlink -m -- "$1" 2>/dev/null || true; }

# True iff $1 (canonical absolute) is equal to or a descendant of $2.
is_inside() {
    local child=$1 parent=$2
    [[ -z "$child" || -z "$parent" ]] && return 1
    [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
}

rand_hex() {
    local n=${1:-16}
    dd if=/dev/urandom bs=1 count="$n" 2>/dev/null | od -An -tx1 | tr -d ' \n'
}

# Uniform random integer in [min, max] via rejection sampling — no modulo bias.
rand_int() {
    local min=$1 max=$2
    local range=$(( max - min + 1 ))
    local limit=$(( 65536 - 65536 % range ))
    local val
    while true; do
        val=$(od -An -N2 -tu2 /dev/urandom | tr -d ' \n')
        (( val < limit )) && { echo $(( min + val % range )); return; }
    done
}

# Return a comma-separated sorted list of random multipass breakpoints chosen
# from {2..levels} via partial Fisher-Yates shuffle. May return empty string.
pick_breakpoints() {
    local levels=$1
    (( levels < 2 )) && { echo ""; return; }
    local n_breaks
    n_breaks=$(rand_int 0 $(( levels - 1 )))
    (( n_breaks == 0 )) && { echo ""; return; }

    local pool=() i
    for i in $(seq 2 "$levels"); do pool+=("$i"); done
    local poolsize=${#pool[@]}

    local picked=() k j tmp
    for (( k=0; k<n_breaks; k++ )); do
        j=$(rand_int "$k" $(( poolsize - 1 )))
        tmp="${pool[$k]}"; pool[$k]="${pool[$j]}"; pool[$j]="$tmp"
        picked+=("${pool[$k]}")
    done
    printf '%s\n' "${picked[@]}" | sort -n | paste -sd ','
}

count_groups() {
    local bp=$1
    [[ -z "$bp" ]] && { echo 1; return; }
    local commas; commas=$(echo "$bp" | tr -cd ',' | wc -c)
    echo $(( commas + 2 ))
}

# Write a hex secret to a mode-0600 file inside BASE_DIR and echo its path.
# The file is created with mktemp (O_EXCL) and chmodded before content is
# written, so the secret never exists on disk with permissive mode.
secret_file() {
    local label=$1 value=$2 path
    path=$(mktemp -p "$BASE_DIR" "secret-${label}-XXXXXXXX")
    is_inside "$(canon "$path")" "$BASE_DIR" || die "secret file escaped base dir: $path"
    chmod 600 -- "$path"
    printf '%s' "$value" > "$path"
    CREATED_SECRETS+=("$path")
    echo "$path"
}

# Overwrite + delete every secret file we created.
_scrub_secrets() {
    local p
    for p in "${CREATED_SECRETS[@]:-}"; do
        [[ -z "$p" ]] && continue
        if [[ -f "$p" && ! -L "$p" ]] && is_inside "$(canon "$p")" "$BASE_DIR"; then
            # Best-effort wipe before unlink (size is small, single pass is fine)
            dd if=/dev/zero of="$p" bs=1 count=4096 conv=notrunc status=none 2>/dev/null || true
            rm -f -- "$p"
        fi
    done
    CREATED_SECRETS=()
}

_safe_remove_file() {
    local cp; cp=$(canon "$1")
    is_inside "$cp" "$BASE_DIR" || { err "refusing to remove out-of-base file: $1"; return; }
    [[ -f "$cp" && ! -L "$cp" ]] && rm -f -- "$cp" || true
}

_safe_remove_dir() {
    local cp; cp=$(canon "$1")
    is_inside "$cp" "$BASE_DIR" || { err "refusing to remove out-of-base dir: $1"; return; }
    [[ -d "$cp" && ! -L "$cp" ]] && rmdir -- "$cp" 2>/dev/null || true
}

# ── cleanup ───────────────────────────────────────────────────────────────────
cleanup() {
    local rc=$?
    local m
    for m in "${ACTIVE_MOUNTDIRS[@]:-}"; do
        [[ -z "$m" ]] && continue
        mountpoint -q -- "$m" 2>/dev/null && run_abyss unmount "$m" >/dev/null 2>&1 || true
    done
    _scrub_secrets
    for m in "${CREATED_CONTAINERS[@]:-}"; do [[ -n "$m" ]] && _safe_remove_file "$m"; done
    for m in "${CREATED_MOUNTDIRS[@]:-}";  do [[ -n "$m" ]] && _safe_remove_dir  "$m"; done
    echo ""
    echo "Results: ${PASS} passed, ${FAIL} failed  ($(( PASS + FAIL )) cycles total)"
    exit "$rc"
}
trap cleanup EXIT INT TERM HUP

# ── argument parsing ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
    case "$1" in
        --cycles)       CYCLES="$2";        shift 2 ;;
        --max-levels)   MAX_LEVELS="$2";    shift 2 ;;
        --fixed-levels) FIXED_LEVELS="$2";  shift 2 ;;
        --no-multipass) ENABLE_MULTIPASS=0; shift   ;;
        --container-mb) CONTAINER_MB="$2";  shift 2 ;;
        --base-dir)     BASE_DIR_RAW="$2";  shift 2 ;;
        --sudo)         USE_SUDO=1;         shift   ;;
        --static-container) STATIC_CONTAINER="$2"; shift 2 ;;
        # Cipher pool pass-through flags forwarded verbatim to abysscrypt
        --with-camellia|--with-blowfish|--with-cast6|--with-anubis)
            POOL_ARGS+=("$1"); shift ;;
        --without-aes|--without-serpent|--without-twofish|\
        --without-sha256|--without-sha512|--without-whirlpool|--without-ripemd160)
            POOL_ARGS+=("$1"); shift ;;
        --cipher) POOL_ARGS+=("--cipher" "$2"); shift 2 ;;
        --hash)   POOL_ARGS+=("--hash"   "$2"); shift 2 ;;
        --abysscrypt)   ABYSSCRYPT="$2";    shift 2 ;;
        -v|--verbose)   VERBOSE=1;          shift   ;;
        -h|--help)     usage; exit 0 ;;
        *) die "unknown option: $1  (try --help)" ;;
    esac
done

# Refuse root + sudo: sudo inside a root shell is a no-op at best, confusing
# at worst. Use --sudo only from a normal user account.
[[ "$(id -u)" -eq 0 && "$USE_SUDO" -eq 1 ]] && \
    die "do not combine --sudo with a root shell; drop --sudo or run as a normal user"

# ── validate abysscrypt binary ────────────────────────────────────────────────
[[ -x "$ABYSSCRYPT" ]] || die "abysscrypt not found or not executable: $ABYSSCRYPT"
ABYSSCRYPT=$(canon "$ABYSSCRYPT")
[[ -x "$ABYSSCRYPT" ]] || die "canonical abysscrypt path not executable: $ABYSSCRYPT"

# ── resolve allowed roots ─────────────────────────────────────────────────────
HOME_CANON=$(canon "${HOME:-/nonexistent}")
PWD_CANON=$(canon "$PWD")
[[ -n "$HOME_CANON" && -d "$HOME_CANON" ]] || die "\$HOME does not resolve to a directory"
[[ -n "$PWD_CANON"  && -d "$PWD_CANON"  ]] || die "\$PWD does not resolve to a directory"

# ── validate base dir ─────────────────────────────────────────────────────────
[[ -z "$BASE_DIR_RAW" ]] && BASE_DIR_RAW="$PWD_CANON/.abysscrypt-test"
BASE_DIR=$(canon "$BASE_DIR_RAW")
[[ -n "$BASE_DIR" ]] || die "could not canonicalise --base-dir: $BASE_DIR_RAW"

if ! is_inside "$BASE_DIR" "$HOME_CANON" && ! is_inside "$BASE_DIR" "$PWD_CANON"; then
    die "base dir must be inside \$HOME ($HOME_CANON) or \$PWD ($PWD_CANON); got: $BASE_DIR"
fi

case "$BASE_DIR" in
    /|/bin|/boot|/dev|/etc|/lib|/lib32|/lib64|/proc|/root|/run|/sbin|/srv|/sys|/usr|/var)
        die "base dir resolves to a system path: $BASE_DIR" ;;
esac

if [[ ! -e "$BASE_DIR" ]]; then
    mkdir -p -- "$BASE_DIR"
elif [[ -L "$BASE_DIR_RAW" ]]; then
    die "base dir is a symlink: $BASE_DIR_RAW"
elif [[ ! -d "$BASE_DIR" ]]; then
    die "base dir exists but is not a directory: $BASE_DIR"
fi

# Reject world-writable without sticky bit
if ! [[ -k "$BASE_DIR" ]]; then
    stat -c '%a' "$BASE_DIR" 2>/dev/null | grep -qE '[2367]$' && \
        die "base dir is world-writable without sticky bit: $BASE_DIR"
fi

BASE_OWNER=$(stat -c '%u' "$BASE_DIR" 2>/dev/null || echo "")
[[ "$BASE_OWNER" == "$(id -u)" ]] || die "base dir not owned by current user: $BASE_DIR"

# ── banner ────────────────────────────────────────────────────────────────────
echo "abysscrypt-test"
echo "  binary:    $ABYSSCRYPT"
echo "  sudo:      $([ "$USE_SUDO" -eq 1 ] && echo "yes (--sudo)" || echo "no (direct)")"
echo "  base dir:  $BASE_DIR"
echo "  cycles:    $CYCLES"
echo "  levels:    $([ "$FIXED_LEVELS" -gt 0 ] && echo "fixed=$FIXED_LEVELS" || echo "random 1..$MAX_LEVELS")"
echo "  multipass: $([ "$ENABLE_MULTIPASS" -eq 1 ] && echo "enabled" || echo "disabled")"
if [[ -n "$STATIC_CONTAINER" ]]; then
    SC_CANON=$(canon "$STATIC_CONTAINER")
    if ! is_inside "$SC_CANON" "$HOME_CANON" && ! is_inside "$SC_CANON" "$PWD_CANON"; then
        die "--static-container must be inside \$HOME or \$PWD: $STATIC_CONTAINER"
    fi
    if [[ ! -f "$SC_CANON" || -L "$SC_CANON" ]]; then
        die "--static-container: not a regular file: $STATIC_CONTAINER"
    fi
    echo "  container: $SC_CANON (static, reused every cycle)"
else
    echo "  container: ${CONTAINER_MB} MiB fresh per cycle"
fi
[[ "${#POOL_ARGS[@]}" -gt 0 ]] && echo "  pool args: ${POOL_ARGS[*]}"
echo ""

# ── main loop ─────────────────────────────────────────────────────────────────
for (( cycle=1; cycle<=CYCLES; cycle++ )); do
    echo "── Cycle ${cycle}/${CYCLES} ────────────────────────────────────────────"

    (( FIXED_LEVELS > 0 )) && LEVELS="$FIXED_LEVELS" || LEVELS=$(rand_int 1 "$MAX_LEVELS")

    BP=""
    N_GROUPS=1
    if (( ENABLE_MULTIPASS && LEVELS > 1 )); then
        BP=$(pick_breakpoints "$LEVELS")
        N_GROUPS=$(count_groups "$BP")
    fi
    log "levels=$LEVELS  groups=$N_GROUPS  breakpoints='$BP'"

    # Container: reuse static path or create a fresh unique one.
    if [[ -n "$STATIC_CONTAINER" ]]; then
        CONTAINER="$STATIC_CONTAINER"
        # Not tracked for deletion — user owns it.
    else
        CONTAINER=$(mktemp -p "$BASE_DIR" "container-c${cycle}-XXXXXXXX.img")
        is_inside "$(canon "$CONTAINER")" "$BASE_DIR" || die "container escaped base dir: $CONTAINER"
        CREATED_CONTAINERS+=("$CONTAINER")
    fi
    MOUNTDIR=$(mktemp -d -p "$BASE_DIR" "mount-c${cycle}-XXXXXXXX")
    is_inside "$(canon "$MOUNTDIR")"  "$BASE_DIR" || die "mountdir escaped base dir: $MOUNTDIR"
    CREATED_MOUNTDIRS+=("$MOUNTDIR")

    # Secrets via mode-0600 files inside BASE_DIR — never in argv or env.
    # File paths appear in argv (visible in /proc/cmdline) but the secret
    # contents do not. The files are wiped + unlinked at end of cycle.
    CIPHER_FILE=$(secret_file "cipher" "$(rand_hex 24)")
    HASH_FILE=$(secret_file   "hash"   "$(rand_hex 24)")

    PASS_ARGS=()
    for (( g=0; g<N_GROUPS; g++ )); do
        pf=$(secret_file "pass${g}" "$(rand_hex 24)")
        PASS_ARGS+=("--passphrase" "file:${pf}")
    done

    MP_ARGS=()
    [[ -n "$BP" ]] && MP_ARGS=("--multipass" "$BP")

    SENTINEL_NAME="sentinel_$(rand_hex 6).txt"
    SENTINEL_BODY="ABYSS_TEST cycle=${cycle} levels=${LEVELS} groups=${N_GROUPS} tag=$(rand_hex 12)"
    CYCLE_OK=1

    if [[ -z "$STATIC_CONTAINER" ]]; then
        dd if=/dev/urandom of="$CONTAINER" bs=1M count="$CONTAINER_MB" status=none conv=notrunc
        log "container filled (${CONTAINER_MB} MiB)"
    else
        log "using static container: $CONTAINER"
    fi
    ACTIVE_MOUNTDIRS+=("$MOUNTDIR")

    # ── first mount (creates filesystem) ──────────────────────────────────────
    # --yes: auto-confirm mkfs (we're non-interactive)
    # --owner: chown the mount point to us so we can write the sentinel
    #          without a second sudo call (which would not match the sudoers
    #          rule that pins /usr/local/bin/abysscrypt)
    set +e
    RUN_OUT=$( run_abyss mount \
        --levels "$LEVELS" "${MP_ARGS[@]}" \
        "${POOL_ARGS[@]}" \
        --cipher-phrase "file:${CIPHER_FILE}" \
        --hash-phrase   "file:${HASH_FILE}" \
        "${PASS_ARGS[@]}" \
        --yes \
        --owner "$(id -u):$(id -g)" \
        "$CONTAINER" "$MOUNTDIR" 2>&1 )
    MOUNT1_RC=$?
    set -e

    [[ "$VERBOSE" -eq 1 ]] && echo "$RUN_OUT" || \
        { echo "$RUN_OUT" | grep -E "error:|warning:|ok$" || true; }

    if (( MOUNT1_RC != 0 )); then
        err "first mount failed (exit $MOUNT1_RC)"
        # Failure: dump the full output regardless of --verbose so the
        # user can see what abysscrypt actually said.
        echo "  ── abysscrypt output (full) ──"
        echo "$RUN_OUT" | sed 's/^/  | /'
        echo "  ── end ──"
        CYCLE_OK=0
    fi

    if (( CYCLE_OK )); then
        # abysscrypt --owner already chowned the mount point for us.
        echo "$SENTINEL_BODY" > "${MOUNTDIR}/${SENTINEL_NAME}"
        log "wrote sentinel: $SENTINEL_NAME"
        run_abyss unmount "$MOUNTDIR" 2>&1 | \
            { [[ "$VERBOSE" -eq 1 ]] && cat || grep -E "error:|warning:" || true; }
    fi

    # ── second mount (verify) ──────────────────────────────────────────────────
    if (( CYCLE_OK )); then
        set +e
        RUN_OUT2=$( run_abyss mount \
            --levels "$LEVELS" "${MP_ARGS[@]}" \
            --cipher-phrase "file:${CIPHER_FILE}" \
            --hash-phrase   "file:${HASH_FILE}" \
            "${PASS_ARGS[@]}" \
            --no-mkfs \
            "$CONTAINER" "$MOUNTDIR" 2>&1 )
        MOUNT2_RC=$?
        set -e

        [[ "$VERBOSE" -eq 1 ]] && echo "$RUN_OUT2" || \
            { echo "$RUN_OUT2" | grep -E "error:|warning:|ok$" || true; }

        if (( MOUNT2_RC != 0 )); then
            err "second mount failed (exit $MOUNT2_RC)"
            echo "  ── abysscrypt output (full) ──"
            echo "$RUN_OUT2" | sed 's/^/  | /'
            echo "  ── end ──"
            CYCLE_OK=0
        fi
    fi

    if (( CYCLE_OK )); then
        if [[ -f "${MOUNTDIR}/${SENTINEL_NAME}" ]]; then
            READBACK=$(cat "${MOUNTDIR}/${SENTINEL_NAME}")
            if [[ "$READBACK" == "$SENTINEL_BODY" ]]; then
                echo "  PASS  levels=$LEVELS  groups=$N_GROUPS  bp='$BP'"
                PASS=$(( PASS + 1 ))
            else
                err "sentinel content mismatch"
                err "  expected: $SENTINEL_BODY"
                err "  got:      $READBACK"
                FAIL=$(( FAIL + 1 ))
            fi
        else
            err "sentinel file missing after remount: $SENTINEL_NAME"
            FAIL=$(( FAIL + 1 ))
        fi
        run_abyss unmount "$MOUNTDIR" 2>&1 | \
            { [[ "$VERBOSE" -eq 1 ]] && cat || grep -E "error:|warning:" || true; }
    else
        FAIL=$(( FAIL + 1 ))
    fi

    _scrub_secrets
done
# EXIT trap: unmounts any still-active mount dirs, removes only paths created
# by this script (re-validated inside base dir), prints final totals.
