#!/usr/bin/env bash
#
# slag.sh - retire a host: inventory, rotation checklist, verified wipe.
# SPDX-License-Identifier: MIT
#
# Three phases, run in order:
#   inventory  - enumerate processes, services, their configs/dirs, secrets,
#                containers, users. Writes a tarball you pull off the box.
#   checklist  - derive a credential rotation/revocation list from the inventory.
#   wipe       - re-exec from tmpfs, hardware-erase (bare metal) and wait for
#                it, zeroize each whole disk (read every block, write
#                zeros only where it is not already zero), read every byte back
#                to verify, and print a receipt of every step's outcome.
#
# The wipe is destructive and irreversible by design. Read the README.
#
# What the verification proves: the targeted devices read back as zero; no
# filesystem on them remains. A sampled verify (--sample) proves only the
# sampled blocks. What it CANNOT prove: hypervisor snapshots, provider
# backups, copies the backing store made before the wipe, or data in SSD
# cells the controller has not yet exposed. Those are covered by rotation +
# snapshot deletion, not by this script. See print_proof_caveats().

set -euo pipefail

VERSION="1.1.0"
PROG="${0##*/}"

# ---- defaults --------------------------------------------------------------

OUTDIR="${SLAG_OUTDIR:-/tmp/slag.$(date -u +%Y%m%dT%H%M%SZ)}"
DRY_RUN=0
ASSUME_YES=0
RECLAIM=0                # after every target verifies: discard, then verify again
STRIKE=1                 # zero key material first (--no-strike turns it off)
STRIKE_ONLY=0            # --strike-only: strike, receipt, stop (NOT a wipe)
STRIKE_FILE=""           # --strike-patterns FILE: added patterns
STRIKE_FILE_N=0
PATTERN=""               # --pattern NAME: a legacy overwrite method after the zero verify
OUTDIR_SET=0             # --out or SLAG_OUTDIR given: its meta/paths.txt feeds the file-extent proof
[[ -n ${SLAG_OUTDIR:-} ]] && OUTDIR_SET=1
TARGETS=()
VERIFY_SAMPLE=0          # 0 = full read-back; N = sample N random blocks instead
HW_ERASE=1               # attempt hardware sanitize/secure-erase on bare metal
HW_TIMEOUT=7200          # seconds to wait for an NVMe sanitize to finish
ALLOW_PARTIAL=0          # allow disks on the host to stay untargeted
ALLOW_LOOP=0             # accept loop devices as targets (testing only)
IN_PLACE_WHOLE=0         # allow in place with a filesystem mounted directly on a whole target disk
FILEMAP_CAP=20000        # most files the file-extent proof maps
SERVE_SPEC=""            # --serve ADDR:PORT as given
SERVE_FOR=24h            # --serve-for: how long the receipt server stays up
SERVE_FOR_SET=""
SEND_URL=""              # --send-receipt URL
SEND_HDRS=()             # --send-receipt-header 'Name: value' (values never logged)
SLAG_LOG="${SLAG_LOG:-}"   # set in the detached wipe: tmpfs copy of all log lines

# ---- output helpers --------------------------------------------------------

c_red=$'\033[31m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_rst=$'\033[0m'
[[ -t 2 ]] || { c_red=; c_grn=; c_ylw=; c_rst=; }

# A failed write to the log copy must never stop a wipe in progress.
_tolog() {
  [[ -n $SLAG_LOG ]] || return 0
  local ts; printf -v ts '%(%H:%M:%S)T' -1
  { printf '%s %s %s\n' "$ts" "$1" "$2" >> "$SLAG_LOG"; } 2>/dev/null || true
}

log()  { printf '%s[slag]%s %s\n'  "$c_grn" "$c_rst" "$*" >&2; _tolog '[slag]' "$*"; }
good() { printf '%s[slag] %s%s\n'  "$c_grn" "$*" "$c_rst" >&2; _tolog '[slag]' "$*"; }
warn() { printf '%s[warn]%s %s\n'   "$c_ylw" "$c_rst" "$*" >&2; _tolog '[warn]' "$*"; }
err()  { printf '%s[error]%s %s\n'  "$c_red" "$c_rst" "$*" >&2; _tolog '[error]' "$*"; }
die()  { err "$*"; exit 1; }

# run CMD ARG...: execute (or, under --dry-run, print) one command. No eval:
# each argument stays one argument.
run() {
  local a line=""
  for a in "$@"; do
    if [[ $a =~ ^[A-Za-z0-9_./=:,+@%-]+$ ]]; then line+=" $a"; else printf -v a ' %q' "$a"; line+=$a; fi
  done
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '%s[dry-run]%s%s\n' "$c_ylw" "$c_rst" "$line" >&2
    return 0
  fi
  _tolog '[exec]' "${line# }"
  "$@"
}

have() { command -v "$1" >/dev/null 2>&1; }

usage() {
  cat <<EOF
$PROG $VERSION - retire a host

USAGE:
  $PROG inventory [--out DIR]
  $PROG checklist [--out DIR]
  $PROG wipe --target /dev/DISK [--target /dev/DISK2 ...]
             [--out DIR] [--reclaim] [--sample N] [--no-hw-erase] [--hw-timeout SECS]
             [--no-strike | --strike-only] [--strike-patterns FILE] [--pattern NAME]
             [--allow-partial] [--allow-loop] [--in-place-whole-disk] [--dry-run] [--yes]
             [--serve ADDR:PORT [--serve-for DURATION]]
             [--send-receipt URL [--send-receipt-header 'Name: value' ...]]
  $PROG patterns                           # list the --pattern overwrite methods
  $PROG selftest                           # stage + self-test the wipe's tools
                                           # in a temp dir; touches no disk
  $PROG all   --target /dev/DEV [...]      # inventory + checklist, then STOP
                                           # (wipe is never chained; run it yourself)

PHASES:
  inventory   Enumerate running processes and systemd services, resolve each to
              its config files, working/state/log/cache dirs and environment
              secrets; capture containers, package-modified configs, every
              user's dotfiles and keys. Output: \$OUTDIR + \$OUTDIR.tar.gz,
              readable by root only (they hold process environments).
  checklist   Read the inventory and emit ROTATION-CHECKLIST.md: the keys,
              certs, tokens, DB creds and host identities that must be rotated
              or revoked because you cannot prove the disk is unrecoverable.
  wipe        Check the targets (whole disks only; every disk on the host must
              be targeted), take a typed confirmation, then copy bash, this
              script and every tool the wipe calls (with their shared
              libraries) into a fresh tmpfs and self-test them there. The wipe
              re-execs from tmpfs DETACHED (systemd unit slag-wipe), so it
              survives the SSH session; this terminal follows its log.
              While the target filesystems are still mounted, the detached run
              maps sensitive files (built-in list plus the inventory's
              meta/paths.txt when --out/SLAG_OUTDIR is given) to their extents
              on the target disks. It then turns swap off, stops services writing to the
              targets, syncs, remounts filesystems read-only (sysrq), sets
              the partitions/dm devices under the targets' mounts read-only
              (blockdev --setro) and stops md/dm devices built on the
              targets. It then STRIKES every target (see --strike-only) and
              only then hardware-erases
              every target (bare metal; once per NVMe controller) and waits
              for it, and only then, for each target: HPA check (sd*),
              wipefs, zeroize, (in-place: then the whole disk is
              set read-only), a read-back verify, and a re-read of every
              mapped file extent. Zeroize reads every block of the device
              (direct I/O, 16 MiB chunks) and writes zeros only to the 64 KiB
              blocks that are not already zero: every block that held data is
              overwritten in place, and blocks that read zero are not written,
              so thin storage (qcow2, thin LVM, Ceph) does not allocate them.
              No discard runs before or during this; see --reclaim. After a
              settle it re-reads every offset where a signature was found
              before the wipe. It ends with a RECEIPT of every step. Afterwards the targeted devices read back as
              zero; no filesystem on them remains. Prefer provider RESCUE
              mode, where the target is not the running root: in-place, the
              running kernel can still write after the receipt.
  selftest    Stage the wipe's tools into a temp dir and run the same self-test
              the wipe runs before touching any disk. Needs no root.

OPTIONS:
  --out DIR        Inventory output dir (default: $OUTDIR). For wipe: the
                   files listed in DIR/meta/paths.txt (directories expand to
                   the regular files under them on the target filesystems; other
                   mounts are not walked; at most $FILEMAP_CAP files on the targets) are
                   mapped to disk extents and re-read after the verify, in
                   addition to a built-in list (/etc/shadow, SSH host keys,
                   TLS/ACME, WireGuard, Kerberos keytab, ~/.ssh, tailscale).
  --target DISK    Whole disk to wipe. Repeatable. REQUIRED for wipe. Symlinks
                   are resolved; partitions, LVM/dm/md and /dev/mapper names are
                   refused, as are duplicates.
  --reclaim        After every target has verified: discard each target
                   (blkdiscard) to give space back to thin storage and TRIM an
                   SSD, then verify every target again (same mode as the first
                   verify). Some SSDs do not read zeros after a discard; with
                   --reclaim that FAILS the device, because the operator asked
                   for that state to be proven. Without --reclaim nothing is
                   ever discarded.
  --no-strike      Skip the strike. By default, before any hardware erase or
                   zeroize, every target is struck: the mapped sensitive-file
                   extents are zeroed, then every byte is read and each 64 KiB
                   block where a key signature matches is zeroed and read back
                   zero (a key's following block too; 16 MiB from a LUKS
                   header). Live files, deleted files, journal and swap alike.
                   Signatures: PEM/OpenSSH/PuTTY/age/GPG private keys, crypt(3)
                   password hashes, WireGuard and tailscale keys, JWK private
                   keys, LUKS headers, AWS key ids, credentials in URLs. Only
                   blocks not already zero are written. If the run is cut
                   short, the secrets are already gone. Costs one more full
                   read of each target.
  --strike-only    Strike, write the receipt and stop: no hardware erase, no
                   zeroize, no verify. The disks are NOT wiped; VERDICT is
                   STRIKE-ONLY and the exit status 3.
  --strike-patterns FILE
                   Add strike signatures: one per line, NAME<TAB>SPAN<TAB>ERE,
                   SPAN in KiB (a multiple of 64) from the match's 64 KiB block,
                   ERE matched by grep -E on raw bytes (LC_ALL=C). # comments.
  --pattern NAME   After every target has verified zero, write a legacy
                   multi-pass overwrite method (DoD 5220.22-M, Gutmann, VSITR,
                   HMG IS5, ...; "slag.sh patterns" lists them) over every
                   byte, then read the last pass back in full: its SHA-256
                   must equal that of the bytes written. The last pass STAYS
                   on the disk, so the disks no longer read back zero; the
                   receipt records the SHA-256 per device. Rescue systems only
                   (refused when a target filesystem is mounted), not with
                   --reclaim or --strike-only. Thin storage becomes fully
                   allocated. Current standards (NIST SP 800-88r2, IEEE 2883)
                   need only the verified zero pass; see docs/PATTERNS.md.
  --sample N       Verify the first block, the last block and N random 1 MiB
                   blocks instead of every byte. Faster on huge disks, but a
                   sampled verify proves only the sampled blocks.
                   Default: full read-back.
  --no-hw-erase    Skip ATA/NVMe/SAS hardware erase (zeroize + verify only).
                   With hardware erase on, every namespace of a targeted NVMe
                   controller must be a target: sanitize erases them all.
  --hw-timeout S   Longest wait for an NVMe sanitize to finish (default: $HW_TIMEOUT s).
  --allow-partial  Allow disks on this host to stay untargeted (they are listed).
                   Without it, any disk not given as --target is refused. It
                   does not cover NVMe namespaces a hardware erase would reach.
  --allow-loop     Accept loop devices as targets. For testing only.
  --in-place-whole-disk
                   Wipe in place even though a target carries a mounted
                   filesystem directly on the whole disk (no partition table,
                   as on many cloud VMs). Refused without this flag: such a
                   disk cannot be set read-only before zeroize, and the
                   running kernel can write the filesystem's superblock back
                   onto it after zeroize cleared it, so the verify or the
                   re-check can fail. Use the provider's rescue system instead.
  --dry-run        Print what wipe would do; touch nothing.
  --yes            Skip the typed confirmation. Required when stdin is not a
                   terminal. For automation. Dangerous.
  --serve ADDR:PORT
                   Serve the receipt over plain HTTP from the tmpfs, so it can
                   be fetched after new SSH logins stop working (sshd re-execs
                   itself from the zeroed disk). ADDR is an IP address on this
                   host (IPv6 in brackets: [2001:db8::1]:8080) or a name that
                   resolves to one; it is resolved before the wipe. Served at
                   http://ADDR:PORT/<random token>/ : RECEIPT.txt,
                   RECEIPT.txt.sha256 and VERDICT once written, and slag.log
                   live during the wipe. Nothing else is reachable: no
                   directory listing, no CGI. Runs busybox httpd from the tmpfs
                   (needs busybox with httpd; a dynamically linked one, as on
                   Finnix, is copied with its libraries) as the systemd unit
                   slag-serve. The pages carry the host name, disk serials and
                   file paths, no secrets; use a private, mesh or VPN address,
                   and check the copy against the SHA-256 printed on the
                   console. A host firewall is never changed; if it may block
                   the port, the wipe warns.
  --serve-for D    How long --serve runs: N, Ns, Nm or Nh (default: $SERVE_FOR).
                   It also ends at power-off.
  --send-receipt URL
                   After the receipt is written, POST it once (raw body,
                   Content-Type: text/plain) to URL (http or https), with curl
                   and a CA bundle copied into the tmpfs and the URL's host
                   resolved before the wipe and pinned (--resolve). Connect
                   timeout 10 s, 60 s per try, up to 3 retries. The body is the
                   receipt as written; the send outcome is then appended to the
                   local RECEIPT.txt after its closing line. A failed send does
                   not change the verdict. Credentials in the URL are refused;
                   the URL's query string is never printed.
  --send-receipt-header 'Name: value'
                   Extra header for --send-receipt (for example an auth
                   token). Repeatable. Only the header names are ever printed.
  -h, --help       This.
  (--passes N is accepted for compatibility and ignored: a second zeroize
   pass would find every block zero and write nothing.)

EXIT: 0 ok; non-zero on any failure, including a verify that finds nonzero data
      or cannot read every byte it was asked to read. 3: --strike-only finished
      (the disks are NOT wiped).
EOF
}

# ---- argument parsing ------------------------------------------------------

PHASE="${1:-}"; shift || true
[[ -z "$PHASE" || "$PHASE" == "-h" || "$PHASE" == "--help" ]] && { usage; exit 0; }

needarg() { [[ $# -ge 2 ]] || die "$1 needs a value (try --help)"; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    --out)           needarg "$@"; OUTDIR="$2"; OUTDIR_SET=1; shift 2 ;;
    --target)        needarg "$@"; TARGETS+=("$2"); shift 2 ;;
    --passes)        needarg "$@"; warn "--passes $2 is ignored: one zeroize pass writes zeros wherever a block is not already zero, so a second pass would write nothing"; shift 2 ;;
    --reclaim)       RECLAIM=1; shift ;;
    --no-strike)     STRIKE=0; shift ;;
    --strike-only)   STRIKE_ONLY=1; shift ;;
    --strike-patterns) needarg "$@"; STRIKE_FILE="$2"; shift 2 ;;
    --pattern)       needarg "$@"; PATTERN="$2"; shift 2 ;;
    --sample)        needarg "$@"; VERIFY_SAMPLE="$2"; shift 2 ;;
    --hw-timeout)    needarg "$@"; HW_TIMEOUT="$2"; shift 2 ;;
    --no-hw-erase)   HW_ERASE=0; shift ;;
    --allow-partial) ALLOW_PARTIAL=1; shift ;;
    --allow-loop)    ALLOW_LOOP=1; shift ;;
    --in-place-whole-disk) IN_PLACE_WHOLE=1; shift ;;
    --dry-run)       DRY_RUN=1; shift ;;
    --yes)           ASSUME_YES=1; shift ;;
    --serve)         needarg "$@"; SERVE_SPEC="$2"; shift 2 ;;
    --serve-for)     needarg "$@"; SERVE_FOR="$2"; SERVE_FOR_SET=1; shift 2 ;;
    --send-receipt)  needarg "$@"; SEND_URL="$2"; shift 2 ;;
    --send-receipt-header) needarg "$@"; SEND_HDRS+=("$2"); shift 2 ;;
    -h|--help)       usage; exit 0 ;;
    *)               die "unknown argument: $1 (try --help)" ;;
  esac
done

# These end up in arithmetic, so only plain decimal numbers get through.
[[ $VERIFY_SAMPLE =~ ^(0|[1-9][0-9]{0,6})$ ]] || die "--sample must be a number from 0 to 9999999"
[[ $HW_TIMEOUT    =~ ^[1-9][0-9]{0,6}$ ]] || die "--hw-timeout must be a number of seconds"
if [[ $SERVE_FOR =~ ^([1-9][0-9]{0,6})([smh]?)$ ]]; then
  case "${BASH_REMATCH[2]}" in
    h) SERVE_SECS=$(( BASH_REMATCH[1] * 3600 )) ;;
    m) SERVE_SECS=$(( BASH_REMATCH[1] * 60 )) ;;
    *) SERVE_SECS=${BASH_REMATCH[1]} ;;
  esac
else
  die "--serve-for must be a duration: N, Ns, Nm or Nh (for example 90m)"
fi
[[ -z $SERVE_FOR_SET || -n $SERVE_SPEC ]] || die "--serve-for needs --serve"
[[ $PHASE == wipe || ( -z $SERVE_SPEC && -z $SEND_URL ) ]] || die "--serve and --send-receipt are wipe options"
[[ ${#SEND_HDRS[@]} -eq 0 || -n $SEND_URL ]] || die "--send-receipt-header needs --send-receipt"
[[ $PHASE == wipe || ( $STRIKE_ONLY -eq 0 && -z $STRIKE_FILE && $STRIKE -eq 1 ) ]] || die "--no-strike, --strike-only and --strike-patterns are wipe options"
[[ $STRIKE_ONLY -eq 0 || $STRIKE -eq 1 ]] || die "--strike-only and --no-strike contradict each other"
[[ $STRIKE_ONLY -eq 0 || $RECLAIM -eq 0 ]] || die "--strike-only does not wipe, so there is nothing to --reclaim"
[[ -z $STRIKE_FILE || $STRIKE -eq 1 ]] || die "--strike-patterns needs the strike (drop --no-strike)"
[[ -z $PATTERN || $PHASE == wipe ]] || die "--pattern is a wipe option"
[[ -z $PATTERN || $STRIKE_ONLY -eq 0 ]] || die "--strike-only does not wipe, so there is no --pattern to write"
[[ -z $PATTERN || $RECLAIM -eq 0 ]] || die "--reclaim discards the disk after the verify; it cannot keep a --pattern on it"
[[ -z $PATTERN || $PATTERN =~ ^[a-z0-9-]{1,20}$ ]] || die "--pattern: unknown name (slag.sh patterns lists them)"

# ===========================================================================
# INVENTORY
# ===========================================================================

# The inventory holds copies of process environments (secrets): the phases
# that write it run under umask 077. A directory that already exists keeps
# its mode, so only warn about it.
inv_private_dir() {
  [[ -n $OUTDIR ]] || die "empty output directory (--out)"
  local mode
  if [[ -d $OUTDIR ]]; then
    mode=$(stat -c %a -- "$OUTDIR" 2>/dev/null) || mode=""
    if [[ ! $mode =~ ^[0-7]+$ ]]; then
      warn "cannot read the mode of $OUTDIR; make sure only root can read it"
    elif (( (8#$mode & 8#077) != 0 )); then
      warn "$OUTDIR already exists with mode $mode (group/other access); the files written into it are root-only, but the directory lists them to others: chmod 700 $OUTDIR"
    fi
  fi
  return 0
}

inv_setup() {
  inv_private_dir
  mkdir -p "$OUTDIR"/{proc,systemd,configs,containers,users,packages,meta}
  {
    echo "slag $VERSION inventory"
    echo "host: $(hostname -f 2>/dev/null || hostname)"
    echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    echo "kernel: $(uname -a)"
    [[ -r /etc/os-release ]] && { echo "--- os-release ---"; cat /etc/os-release; }
    echo "--- virt ---"; systemd-detect-virt 2>/dev/null || echo "unknown"
  } > "$OUTDIR/meta/host.txt" 2>&1 || true
}

# Resolve a path list to real files we care about, skipping /proc /sys /dev.
collect_paths() {
  local dest="$1"; shift
  local p
  for p in "$@"; do
    [[ -z "$p" || "$p" == "-" ]] && continue
    case "$p" in /proc/*|/sys/*|/dev/*) continue ;; esac
    if [[ -e "$p" ]]; then printf '%s\n' "$p" >> "$dest"; fi
  done
  return 0   # never let a short-circuited test abort the caller under set -e
}

# mi_unescape S: a mountinfo field with its octal escapes (\040 etc.) decoded.
mi_unescape() {
  local s="$1" out="" c
  while [[ $s =~ ^([^\\]*)\\([0-7]{3})(.*)$ ]]; do
    printf -v c "\\${BASH_REMATCH[2]}"
    out+=${BASH_REMATCH[1]}$c; s=${BASH_REMATCH[3]}
  done
  printf '%s' "$out$s"
}

declare -A INV_MNTS=()      # mountpoints on this host, for inv_cwd

# inv_cwd PATH: a working directory is recorded only when it is a directory
# of its own: `/` and other mount points are where daemons chdir by default,
# and would make the file-extent proof walk a whole filesystem.
inv_cwd() {
  local mp
  [[ -n $1 && $1 != / ]] || return 0
  if [[ ${#INV_MNTS[@]} -eq 0 ]]; then
    INV_MNTS[/]=1
    { while read -r _ _ _ _ mp _; do INV_MNTS[$(mi_unescape "$mp")]=1; done < /proc/self/mountinfo; } 2>/dev/null || true
  fi
  [[ -n ${INV_MNTS[$1]:-} ]] && return 0
  collect_paths "$OUTDIR/meta/paths.txt" "$1"
}

inv_processes() {
  log "inventory: processes"
  local pid exe cwd
  ps -eo pid,ppid,user,comm,args --no-headers > "$OUTDIR/proc/ps.txt" 2>/dev/null || true

  for pid in $(ps -eo pid --no-headers 2>/dev/null | tr -d ' '); do
    [[ "$pid" =~ ^[0-9]+$ ]] || continue
    local d="$OUTDIR/proc/$pid"
    mkdir -p "$d"
    exe=$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)
    cwd=$(readlink -f "/proc/$pid/cwd" 2>/dev/null || true)
    {
      echo "pid: $pid"
      echo "exe: $exe"
      echo "cwd: $cwd"
      echo "--- cmdline ---"; tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null; echo
      echo "--- environ (SECRETS LIKELY) ---"; tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null || true
      echo "--- open files ---"; ls -l "/proc/$pid/fd" 2>/dev/null || true
    } > "$d/info.txt" 2>&1 || true

    # Record interesting paths this process touches.
    inv_cwd "$cwd"
    if have lsof; then
      # regular files the process has open
      while read -r f; do collect_paths "$OUTDIR/meta/paths.txt" "$f"; done \
        < <(lsof -p "$pid" -Fn 2>/dev/null | sed -n 's/^n//p' | grep '^/' || true)
    fi
  done
}

inv_systemd() {
  have systemctl || { warn "no systemctl; skipping service inventory"; return 0; }
  log "inventory: systemd services"
  systemctl list-units --type=service --all --no-legend --no-pager \
    > "$OUTDIR/systemd/units.txt" 2>/dev/null || true

  local unit props
  props="FragmentPath DropInPaths ExecStart EnvironmentFiles WorkingDirectory \
StateDirectory ConfigurationDirectory LogsDirectory CacheDirectory \
RuntimeDirectory ReadWritePaths User Group"
  # shellcheck disable=SC2086
  while read -r unit _; do
    [[ "$unit" == *.service ]] || continue
    local safe="${unit//\//_}"
    systemctl show "$unit" -p "${props// /,}" --no-pager \
      > "$OUTDIR/systemd/$safe.txt" 2>/dev/null || true
    # Harvest file/dir paths from the show output.
    while IFS='=' read -r k v; do
      case "$k" in
        FragmentPath|DropInPaths|EnvironmentFiles|WorkingDirectory|StateDirectory|\
        ConfigurationDirectory|LogsDirectory|CacheDirectory|ReadWritePaths)
          # values may be space-separated lists; StateDirectory et al are
          # relative to /var/lib, /etc, /var/log, /var/cache respectively.
          local token
          for token in $v; do
            case "$k" in
              StateDirectory)         collect_paths "$OUTDIR/meta/paths.txt" "/var/lib/$token" ;;
              ConfigurationDirectory) collect_paths "$OUTDIR/meta/paths.txt" "/etc/$token" ;;
              LogsDirectory)          collect_paths "$OUTDIR/meta/paths.txt" "/var/log/$token" ;;
              CacheDirectory)         collect_paths "$OUTDIR/meta/paths.txt" "/var/cache/$token" ;;
              WorkingDirectory)       inv_cwd "${token#!}" ;;
              *)                      collect_paths "$OUTDIR/meta/paths.txt" "$token" ;;
            esac
          done
          ;;
      esac
    done < "$OUTDIR/systemd/$safe.txt"
  done < "$OUTDIR/systemd/units.txt"
}

inv_packages() {
  log "inventory: package-owned and modified configs"
  if have dpkg-query; then
    dpkg-query -W -f='${Conffiles}\n' 2>/dev/null \
      | awk '{print $1}' > "$OUTDIR/packages/conffiles.txt" || true
    have debsums && debsums -ce 2>/dev/null > "$OUTDIR/packages/modified-configs.txt" || true
  elif have rpm; then
    rpm -qac 2>/dev/null > "$OUTDIR/packages/conffiles.txt" || true
    rpm -Va 2>/dev/null | grep -E '^..5' > "$OUTDIR/packages/modified-configs.txt" || true
  fi
  # Files written after provisioning are the interesting set.
  local anchor=/var/log/installer
  [[ -e /root/.bash_history ]] && anchor=/root/.bash_history
  find / -xdev -type f -newer "$anchor" 2>/dev/null \
    > "$OUTDIR/packages/post-provision-files.txt" || true
}

inv_containers() {
  have docker || return 0
  log "inventory: docker"
  docker ps -aq 2>/dev/null | while read -r cid; do
    docker inspect -f '{{.Name}} mounts={{json .Mounts}} env={{json .Config.Env}}' \
      "$cid" 2>/dev/null
  done > "$OUTDIR/containers/docker.txt" || true
  echo "/var/lib/docker" >> "$OUTDIR/meta/paths.txt"
}

inv_users() {
  log "inventory: users, dotfiles, keys"
  local homes; homes=$(awk -F: '($3>=0){print $6}' /etc/passwd 2>/dev/null | sort -u)
  {
    echo "/root"; echo "$homes"
  } | sort -u | while read -r h; do
    [[ -d "$h" ]] || continue
    local sub
    for sub in .ssh .gnupg .aws .config .local/share .cache .bash_history \
               .zsh_history .netrc .docker .kube .pgpass; do
      [[ -e "$h/$sub" ]] && echo "$h/$sub" >> "$OUTDIR/meta/paths.txt"
    done
  done
  # Always-sensitive system locations.
  for p in /etc/shadow /etc/ssh /etc/ssl/private /etc/letsencrypt \
           /var/lib/cloud /var/log/journal /swap.img /swapfile; do
    [[ -e "$p" ]] && echo "$p" >> "$OUTDIR/meta/paths.txt"
  done
  swapon --show=NAME --noheadings 2>/dev/null >> "$OUTDIR/meta/paths.txt" || true
}

inv_finalize() {
  sort -u "$OUTDIR/meta/paths.txt" -o "$OUTDIR/meta/paths.txt" 2>/dev/null || true
  local n; n=$(wc -l < "$OUTDIR/meta/paths.txt" 2>/dev/null || echo 0)
  log "inventory: $n sensitive paths catalogued"
  local tgz="$OUTDIR.tar.gz"
  # An archive left by an earlier run keeps its mode when tar rewrites it.
  if tar czf "$tgz" -C "$(dirname "$OUTDIR")" "$(basename "$OUTDIR")" 2>/dev/null \
     && chmod 600 -- "$tgz"; then
    log "inventory archive: $tgz (mode 600)"
  else
    warn "could not create archive $tgz (or make it root-only)"
  fi
  log "PULL $tgz OFF THIS HOST before wiping."
}

phase_inventory() {
  umask 077
  inv_setup
  inv_processes
  inv_systemd
  inv_packages
  inv_containers
  inv_users
  inv_finalize
}

# ===========================================================================
# CHECKLIST
# ===========================================================================

phase_checklist() {
  umask 077
  [[ -n $OUTDIR && -d $OUTDIR ]] || die "no inventory at $OUTDIR; run '$PROG inventory' first"
  local out="$OUTDIR/ROTATION-CHECKLIST.md"
  log "checklist: deriving from inventory -> $out"

  {
    echo "# Rotation / revocation checklist"
    echo
    echo "Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) from inventory at \`$OUTDIR\`."
    echo
    echo "> You cannot prove the disk is unrecoverable from inside the guest."
    echo "> Treat everything that lived on this host as compromised and rotate it."
    echo

    echo "## SSH"
    if grep -rqs . "$OUTDIR"/users 2>/dev/null || ls /etc/ssh/*key* >/dev/null 2>&1; then
      echo "- [ ] Remove this host's SSH **host keys** from clients' known_hosts."
      echo "- [ ] Rotate any **user SSH keys** found under ~/.ssh (listed in inventory)."
      echo "- [ ] Revoke SSH **CA-signed certs** issued to/from this host."
    fi
    echo

    echo "## TLS / PKI"
    if grep -Eqs 'letsencrypt|ssl/private' "$OUTDIR/meta/paths.txt" 2>/dev/null; then
      echo "- [ ] **Revoke** TLS certs whose private keys lived here (do not just reissue)."
      echo "- [ ] Rotate ACME account keys if present."
    fi
    echo

    echo "## Application / API secrets (from process environments)"
    # Pull likely secret env var names out of captured environ blocks.
    local hits
    hits=$(grep -rhoE '^[A-Z0-9_]*(KEY|TOKEN|SECRET|PASS|PASSWORD|CRED|DSN|URL)[A-Z0-9_]*=' \
             "$OUTDIR"/proc/*/info.txt 2>/dev/null | sed 's/=.*//' | sort -u || true)
    if [[ -n "$hits" ]]; then
      echo "Environment variables seen on running processes that look like secrets:"
      echo '```'
      echo "$hits"
      echo '```'
      echo "- [ ] Rotate each of the above at its source of truth."
    else
      echo "- [ ] (No secret-looking env vars auto-detected; check proc/*/info.txt manually.)"
    fi
    echo

    echo "## Databases"
    if grep -Eqs 'postgres|mysql|redis|mongo' "$OUTDIR/proc/ps.txt" 2>/dev/null; then
      echo "- [ ] Rotate DB user passwords; a dump of this host exposed them."
      echo "- [ ] Check for WAL/redo/backup files in the data dirs (inventory paths)."
    fi
    echo

    echo "## Host identity / infra"
    echo "- [ ] Remove host from directory (LDAP/Kerberos), monitoring, config mgmt."
    echo "- [ ] Remove DNS records pointing here."
    echo "- [ ] Revoke this host's access to backup targets (e.g. B2/S3 keys)."
    echo "- [ ] Rotate any cloud-init / metadata secrets (see /var/lib/cloud in inventory)."
    echo

    echo "## Provider side (OUTSIDE the guest - do by hand)"
    echo "- [ ] Delete **all snapshots** of this instance."
    echo "- [ ] Delete **all backups / images** you created from it."
    echo "- [ ] **Destroy** the instance (not stop)."
    echo
  } > "$out"

  log "checklist written: $out"
  [[ -t 1 ]] && sed -n '1,60p' "$out" || true
}


# ===========================================================================
# WIPE
# ===========================================================================

# Every external command the wipe calls after the re-exec from tmpfs. Each is
# copied with its shared libraries so it still runs once the root fs is gone.
WIPE_TOOLS=(dd cmp od blockdev blkdiscard wipefs nvme hdparm sg_sanitize sync
            lsblk realpath sleep systemctl journalctl swapoff timeout mdadm dmsetup
            filefrag find sha256sum grep cat head tee tr wc)
# Without these the verify cannot run, so the self-test fails if any is broken.
CORE_TOOLS=(bash dd cmp od blockdev)
BB_APPLETS=(sh dd cmp od head cat sync sleep wc sha256sum tee tr)
UNIT=slag-wipe
SAMPLE_BS=$((1024 * 1024))
SETTLE_SECS=10              # in-place: wait before re-reading the signature offsets
SIG_BS=$((64 * 1024))       # block re-read around each signature offset

RES=()                      # step results: dev<TAB>step<TAB>outcome<TAB>detail
declare -A DEV_MODEL=() DEV_SERIAL=() DEV_SIZE=() DEV_VERIFY=() DEV_VERDICT=()
declare -A DEV_SIGS=()      # device -> byte offsets of signatures found before the wipe
declare -A DEV_NORM=()      # device -> "ok READ WRITTEN", "FAILED READ WRITTEN WHY" or dry-run
declare -A DEV_VOK=()       # device -> full|sampled once the first verify passed
declare -A DEV_EXT=()       # device -> file-extent proof summary for the receipt
declare -A TGT_DEVS=()      # names, kernel names and MAJ:MIN of the target trees
declare -A TGT_MNT=()       # mount id -> mountpoint, for mounts on the targets
declare -A WHOLE_FS=()      # target disk -> mountpoint of a filesystem directly on it (in place)
declare -A MNT_SCAN=()      # scratch for scan_mounts
declare -A WRITER_UNITS=() WRITER_OTHER=()
HOLDERS=()
DM_TYPES=""
HOLDER_WHY=""
INTERP=()
NVME_HW=""                  # set by nvme_erase: sanitized, running or empty
QUIESCE_FAILED=0
IN_PLACE=0
STARTED=""

now() { printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1; }

lastline() {
  local s="${1%$'\n'}"
  s=${s##*$'\n'}
  printf '%s' "${s:0:200}"
}

trim() {
  local s="$1"
  s=${s#"${s%%[![:space:]]*}"}
  s=${s%"${s##*[![:space:]]}"}
  printf '%s' "$s"
}

# record DEV STEP OUTCOME [DETAIL]; OUTCOME is ok, unsupported, frozen, failed
# or skipped (ok becomes dry-run under --dry-run).
record() {
  local dev="$1" step="$2" outcome="$3" detail="${4:-}"
  [[ $DRY_RUN -eq 1 && $outcome == ok ]] && outcome=dry-run
  detail=${detail//[$'\t\n\r']/ }
  RES+=("$dev"$'\t'"$step"$'\t'"$outcome"$'\t'"$detail")
  case "$outcome" in
    ok|dry-run|skipped) log "  [$outcome] $dev $step${detail:+ - $detail}" ;;
    *)                  warn "  [$outcome] $dev $step${detail:+ - $detail}" ;;
  esac
}

# step DEV NAME CMD ARG...: run one command as a recorded step (ok or failed).
step() {
  local dev="$1" name="$2"; shift 2
  if [[ $DRY_RUN -eq 1 ]]; then run "$@"; record "$dev" "$name" ok; return 0; fi
  local out rc=0
  out=$(run "$@" 2>&1 </dev/null) || rc=$?
  if [[ $rc -eq 0 ]]; then
    record "$dev" "$name" ok "$(lastline "$out")"
    return 0
  fi
  record "$dev" "$name" failed "rc=$rc: $(lastline "$out")"
  return "$rc"
}

lsfield() {
  local v
  v=$(lsblk -dno "$2" -- "$1" 2>/dev/null) || v=
  v=$(trim "$v")
  printf '%s' "${v:-?}"
}

is_rotational() {
  local r="/sys/block/${1##*/}/queue/rotational" v=""
  [[ -r $r ]] && read -r v < "$r"
  [[ $v == 1 ]]
}

dev_size() {
  local sz
  if [[ $DRY_RUN -eq 1 ]]; then
    sz=$(lsblk -bdno SIZE -- "$1" 2>/dev/null) || sz=
  else
    sz=$(blockdev --getsize64 "$1") || return 1
  fi
  sz=${sz//[[:space:]]/}
  [[ $sz =~ ^[0-9]+$ && $sz -gt 0 ]] || return 1
  printf '%s' "$sz"
}

print_proof_caveats() {
  cat >&2 <<EOF
${c_ylw}
PROOF MODEL - read before trusting the result
---------------------------------------------
HOW (zeroize, then verify):
  * zeroize reads every block and writes zeros only where a block is not
    already zero: every block that held data is overwritten in place; blocks
    that read zero were not written (on thin storage they stay unallocated)
  * nothing is discarded before or during this; --reclaim discards only after
    every target has verified, then verifies again
  * on SSDs the controller remaps writes, so in-place zeros may land on new
    cells; the hardware sanitize/secure-erase (bare metal) covers worn cells

PROVES (via full read-back):
  * the targeted devices read back as zero; no filesystem on them remains
  * a sampled verify (--sample) proves only the sampled blocks
  * the extents of the mapped sensitive files (built-in list + inventory
    paths) read back as zero, re-read on their own after the verify

DOES NOT PROVE (no in-guest tool can):
  * hypervisor/provider SNAPSHOTS or BACKUPS are gone  -> delete them by hand
  * the backing store (Ceph/SAN/thin-LVM) kept no COPY of old blocks;
    thin-storage SNAPSHOTS remain a provider-side job
  * SSD flash cells not currently exposed by the controller are clean
    -> that is what the hardware sanitize/secure-erase step is for (bare metal)
  * sectors an ATA DCO hides (an HPA on sd* disks is checked and fails the run)
  * IN-PLACE: that the still-running kernel writes nothing to a target after
    the receipt (its mounts are read-only on devices set read-only, and the
    signature offsets are re-read after a settle, but nothing reads later)

Mitigation for everything in the second list = the rotation checklist.
Rotate/revoke every credential so a recovered block is worthless.
${c_rst}
EOF
  if [[ -n $SERVE_SPEC || -n $SEND_URL ]]; then
    cat >&2 <<EOF
${c_ylw}RECEIPT COPIES (--serve / --send-receipt): a copy fetched or received over
the network proves nothing by itself; compare it with the SHA-256 printed on
the console at the end. --serve is plain HTTP.
${c_rst}
EOF
  fi
}

# ---- target validation -----------------------------------------------------

# Whole disks only, no duplicates, and every disk on the host accounted for:
# a partition, an LVM/dm/md volume or an untargeted disk leaves data behind
# (partition tables, RAID metadata, other volumes, another disk's EFI
# partition). Rewrites TARGETS to the resolved device paths.
resolve_targets() {
  local t real type list name size resolved=() missing=() sib=()
  local -A seen=() ctrl=()
  for t in "${TARGETS[@]}"; do
    real=$(realpath -e -- "$t" 2>/dev/null) || die "$t: no such device"
    [[ -b $real ]] || die "$t ($real) is not a block device"
    type=$(lsblk -dno TYPE -- "$real") || die "lsblk cannot describe $real"
    type=$(trim "$type")
    case "$type" in
      disk) ;;
      loop) [[ $ALLOW_LOOP -eq 1 ]] || die "$real is a loop device; pass --allow-loop (testing only)" ;;
      *)    die "$t ($real) is TYPE=$type, not a whole disk. Wipe whole disks only: partitions, LVM/dm/md and /dev/mapper names leave partition tables, RAID metadata and other volumes behind" ;;
    esac
    [[ -z ${seen[$real]:-} ]] || die "$t duplicates ${seen[$real]} (both are $real)"
    seen[$real]=$t
    resolved+=("$real")
  done
  TARGETS=("${resolved[@]}")

  list=$(lsblk -dbnrpo NAME,TYPE,SIZE) || die "lsblk cannot list the disks on this host"

  # NVMe sanitize (and a crypto format on drives with FNA set) erases every
  # namespace behind the controller, so an untargeted sibling namespace would
  # be destroyed while the receipt calls it untouched. nvmeXnY groups by X:
  # the controller, or the subsystem under native multipath.
  if [[ $HW_ERASE -eq 1 ]]; then
    for t in "${TARGETS[@]}"; do
      [[ ${t##*/} =~ ^(nvme[0-9]+)n[0-9]+$ ]] && ctrl[${BASH_REMATCH[1]}]=1
    done
    while read -r name type size; do
      [[ $type == disk && ${name##*/} =~ ^(nvme[0-9]+)n[0-9]+$ ]] || continue
      [[ -n ${ctrl[${BASH_REMATCH[1]}]:-} && -z ${seen[$name]:-} ]] && sib+=("$name")
    done <<< "$list"
    [[ ${#sib[@]} -eq 0 ]] \
      || die "NVMe hardware erase covers every namespace on the controller, but these namespaces are not targeted: ${sib[*]}. Target them too, or pass --no-hw-erase (--allow-partial does not cover this)"
  fi

  while read -r name type size; do
    [[ $type == disk ]] || continue                # skips loop, rom, part, ...
    case "${name##*/}" in zram*|ram*) continue ;; esac
    [[ $size == 0 ]] && continue                   # empty card-reader slot etc.
    [[ -n ${seen[$name]:-} ]] || missing+=("$name")
  done <<< "$list"
  if [[ ${#missing[@]} -gt 0 ]]; then
    if [[ $ALLOW_PARTIAL -eq 1 ]]; then
      warn "not targeted (--allow-partial): ${missing[*]} - data on them is untouched"
    else
      die "disks on this host that are not targeted: ${missing[*]}. Target every disk, or pass --allow-partial if leaving them is intended"
    fi
  fi
  return 0
}

collect_target_tree() {
  local t out name kname majmin
  TGT_DEVS=()
  for t in "${TARGETS[@]}"; do
    out=$(lsblk -nrpo NAME,KNAME,MAJ:MIN -- "$t") || { warn "lsblk could not list the devices on $t"; continue; }
    while read -r name kname majmin; do
      [[ -n $name ]] || continue
      TGT_DEVS[$name]=1; TGT_DEVS[$kname]=1; TGT_DEVS[$majmin]=1
    done <<< "$out"
  done
  return 0
}

# scan_mounts MOUNTINFO KEYPREFIX: set MNT_SCAN[KEYPREFIX<mount id>]=mountpoint
# for every mount backed by a target. Match on MAJ:MIN and on the mount
# source: btrfs reports an anonymous 0:N.
scan_mounts() {
  local id majmin mp rest src
  while read -r id _ majmin _ mp _ rest; do
    src=" $rest"; src=${src#* - }; read -r _ src _ <<< "$src"
    if [[ -n ${TGT_DEVS[$majmin]:-} ]] || { [[ -n $src ]] && [[ -n ${TGT_DEVS[$src]:-} ]]; }; then
      MNT_SCAN[$2$id]=$mp
    fi
  done < "$1"
}

collect_target_mounts() {
  local k
  MNT_SCAN=() TGT_MNT=()
  scan_mounts /proc/self/mountinfo ""
  for k in "${!MNT_SCAN[@]}"; do TGT_MNT[$k]=${MNT_SCAN[$k]}; done
  return 0
}

# mount_kname MAJ:MIN SOURCE: the kernel name of the block device under a
# mount (btrfs reports an anonymous 0:N, so the source is tried too).
mount_kname() {
  local key val k=""
  if [[ -n ${TGT_DEVS[$1]:-} ]]; then
    { while IFS='=' read -r key val; do [[ $key == DEVNAME ]] && k=$val; done \
        < "/sys/dev/block/$1/uevent"; } 2>/dev/null || true
  elif [[ -b $2 && -n ${TGT_DEVS[$2]:-} ]]; then
    k=$(realpath -- "$2" 2>/dev/null </dev/null) || k=""
    k=${k##*/}
  fi
  printf '%s' "$k"
}

# Mounted filesystems that sit directly on a whole target disk (no partition
# table), one "MOUNTPOINT on /dev/DISK" per line. In place, such a disk cannot
# be set read-only before zeroize: zeroize writes the disk itself.
whole_disk_mounts() {
  local id majmin mp rest src k
  local -A seen=()
  collect_target_tree
  collect_target_mounts
  while read -r id _ majmin _ mp _ rest; do
    [[ -n ${TGT_MNT[$id]:-} ]] || continue
    src=" $rest"; src=${src#* - }; read -r _ src _ <<< "$src"
    k=$(mount_kname "$majmin" "$src")
    if [[ -z $k ]] || ! is_target "$k"; then continue; fi
    [[ -z ${seen[$mp $k]:-} ]] || continue
    seen[$mp $k]=1
    printf '%s on /dev/%s\n' "$(mi_unescape "$mp")" "$k"
  done < /proc/self/mountinfo
  return 0
}

# Kernel names of every block device stacked on the targets or their
# partitions (md, dm/LVM/crypt, bcache, ...), breadth first: each holder comes
# after what it holds, so reverse order is a safe stop order.
collect_holders() {
  local t p x h i=0 q=()
  local -A seen=()
  HOLDERS=()
  for t in "${TARGETS[@]}"; do
    q+=("${t##*/}")
    for p in "/sys/block/${t##*/}/${t##*/}"*; do [[ -d $p ]] && q+=("${p##*/}"); done
  done
  while (( i < ${#q[@]} )); do
    x=${q[i]}; i=$(( i + 1 ))
    for h in "/sys/class/block/$x/holders/"*; do
      [[ -e $h ]] || continue
      h=${h##*/}
      [[ -z ${seen[$h]:-} ]] || continue
      seen[$h]=1; q+=("$h"); HOLDERS+=("$h")
    done
  done
  return 0
}

show_targets() {
  local d nvme=0
  collect_target_tree
  collect_target_mounts
  printf '\n%sTARGETS (every byte on these is destroyed):%s\n' "$c_red" "$c_rst" >&2
  for d in "${TARGETS[@]}"; do
    printf '  %-16s model=%s  serial=%s  size=%s\n' "$d" \
      "$(lsfield "$d" MODEL)" "$(lsfield "$d" SERIAL)" "$(lsfield "$d" SIZE)" >&2
    [[ ${d##*/} == nvme*n* ]] && nvme=1
  done
  echo >&2
  if [[ ${#TGT_MNT[@]} -gt 0 ]]; then
    warn "IN-PLACE wipe: mounted from the targets: ${TGT_MNT[*]}"
    warn "the wipe re-execs from tmpfs, stops services writing to them, remounts"
    warn "EVERY block-backed filesystem on this host read-only (sysrq u) and sets the"
    warn "devices under the target mounts read-only (blockdev --setro). The host"
    warn "will not survive. Even with the wipe in tmpfs, PID 1, sshd and the kernel"
    warn "still run from the root fs: if they page in code after zeroize the"
    warn "host can hang or panic mid-wipe, and the receipt then only reaches the console."
    warn "Prefer provider RESCUE mode (target not the running root)."
  fi
  if [[ $nvme -eq 1 && $HW_ERASE -eq 1 ]]; then
    warn "NVMe sanitize erases EVERY namespace on the controller, not only the target namespace;"
    warn "every namespace of each targeted controller is targeted, and the hardware erase of all"
    warn "targets runs before any zeroize or verify."
  fi
  collect_holders
  if [[ ${#HOLDERS[@]} -gt 0 ]]; then
    warn "block devices built on the targets: ${HOLDERS[*]}"
    warn "the wipe stops each one not in use (mdadm --stop, dmsetup remove). An md array or a"
    warn "dm device other than a plain linear/striped/crypt mapping that stays active (it"
    warn "carries a mounted filesystem) can write to a target after its verify: the run then FAILS."
  fi
  return 0
}

confirm_wipe() {
  [[ $DRY_RUN -eq 1 || $ASSUME_YES -eq 1 ]] && return 0
  local t w want=() typed=()
  local -A need=()
  for t in "${TARGETS[@]}"; do want+=("${t#/dev/}"); need[${t#/dev/}]=1; done
  printf '%sType the device names to wipe, separated by spaces ("%s"): %s' \
    "$c_red" "${want[*]}" "$c_rst" >&2
  read -r -a typed || die "not confirmed; aborting"
  [[ ${#typed[@]} -eq ${#want[@]} ]] || die "not confirmed (expected: ${want[*]}); aborting"
  for w in "${typed[@]}"; do
    [[ -n ${need[$w]:-} ]] || die "not confirmed ($w is not a target); aborting"
    unset "need[$w]"
  done
  [[ ${#need[@]} -eq 0 ]] || die "not confirmed (expected: ${want[*]}); aborting"
  return 0
}

# ---- getting the receipt off the machine (--serve, --send-receipt) --------

# After an in-place wipe new SSH logins usually fail (sshd re-execs itself
# from the zeroed disk for each connection), so the receipt is otherwise
# reachable only on the console or an SSH session that was already open.
SERVE_UNIT=slag-serve
SERVE_ADDR="" SERVE_PORT="" SERVE_BIND="" SERVE_URL="" SERVE_TOKEN="" SERVE_NAME="" SERVE_FW=""
SEND_ON="" SEND_SHOW="" SEND_PINS="" SEND_HNAMES="" SEND_CA="" RECEIPT_SHA=""
CA_BUNDLES=(/etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt
            /etc/ssl/ca-bundle.pem /etc/ssl/cert.pem)

# resolve_addrs NAME: the distinct addresses getent ahosts gives for NAME, one
# per line. An IP literal comes back in its canonical form.
resolve_addrs() {
  local out a _r
  local -A seen=()
  have getent || return 1
  out=$(getent ahosts "$1" 2>/dev/null) || return 1
  while read -r a _r; do
    [[ -n $a && -z ${seen[$a]:-} ]] || continue
    seen[$a]=1
    printf '%s\n' "$a"
  done <<< "$out"
}

# The host's busybox: HOST_BB its path, HOST_BB_KIND static or dynamic. A
# static one is staged as is (and its applets back up missing tools); a
# dynamic one (Finnix, Debian's busybox package) is staged with its shared
# libraries and run through the tmpfs loader, for --serve only.
host_busybox() {
  local out
  HOST_BB="" HOST_BB_KIND=""
  # Alpine's busybox-static installs busybox.static beside the dynamic busybox.
  HOST_BB=$(type -P busybox.static) || HOST_BB=$(type -P busybox) || return 1
  out=$(ldd "$HOST_BB" 2>&1) || true
  if [[ $out == *"=>"* ]]; then HOST_BB_KIND=dynamic; else HOST_BB_KIND=static; fi
  return 0
}

# bb_has APPLET BUSYBOX_CMD...: the busybox the command runs has APPLET.
# Bash only: it also runs from the tmpfs.
bb_has() {
  local a applet="$1"; shift
  while IFS= read -r a; do [[ $a == "$applet" ]] && return 0; done < <("$@" --list 2>/dev/null)
  return 1
}

# ld_flags LOADER: options for running a program through that dynamic loader.
# glibc's ld.so gets --inhibit-cache (libraries only from --library-path); musl's
# (Alpine) has no cache and rejects the option, printing its usage instead.
ld_flags() {
  LDF=()
  [[ $1 == ld-musl* ]] || LDF=(--inhibit-cache)
}

# ram_bb RAM: RAM_BB is the command that runs the busybox staged in RAM: the
# static copy in bb/, or a dynamic one in bin/ through the tmpfs loader.
ram_bb() {
  local ld
  RAM_BB=()
  if [[ -x $1/bb/busybox ]]; then
    RAM_BB=("$1/bb/busybox")
  elif [[ -x $1/bin/busybox && -s $1/loader ]] && ram_is_dynamic "$1" busybox; then
    read -r ld < "$1/loader"; ld_flags "$ld"
    RAM_BB=("$1/lib/$ld" "${LDF[@]}" --library-path "$1/lib" "$1/bin/busybox")
  else
    return 1
  fi
  return 0
}

# A host firewall that may drop connections to the served port. Only read.
firewall_note() {
  local out notes=()
  if have nft && out=$(nft list ruleset 2>/dev/null) && [[ $out == *"hook input"* ]]; then
    notes+=("nftables has input rules")
  fi
  if have iptables && out=$(iptables -S INPUT 2>/dev/null) && [[ $out == *"-A INPUT"* || $out == *"-P INPUT DROP"* ]]; then
    notes+=("iptables has INPUT rules")
  fi
  if [[ $SERVE_ADDR == *:* ]] && have ip6tables && out=$(ip6tables -S INPUT 2>/dev/null) \
     && [[ $out == *"-A INPUT"* || $out == *"-P INPUT DROP"* ]]; then
    notes+=("ip6tables has INPUT rules")
  fi
  if have ufw && out=$(ufw status 2>/dev/null) && [[ $out == *"Status: active"* ]]; then
    notes+=("ufw is active")
  fi
  local IFS=,
  printf '%s' "${notes[*]}"
}

# --serve ADDR:PORT: resolve ADDR to an address on this host, check busybox
# httpd and the port, pick the path token. Before the confirmation.
serve_check() {
  local spec="$SERVE_SPEC" host addrs a bb busy
  local -A mine=()
  if [[ $spec =~ ^\[([0-9A-Fa-f:.]+)\]:([0-9]+)$ ]]; then
    host=${BASH_REMATCH[1]} SERVE_PORT=${BASH_REMATCH[2]}
  elif [[ $spec =~ ^([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?):([0-9]+)$ ]]; then
    host=${BASH_REMATCH[1]} SERVE_PORT=${BASH_REMATCH[3]}
  elif [[ $spec == *:*:* ]]; then
    die "--serve $spec: put an IPv6 address in brackets: [ADDR]:PORT"
  else
    die "--serve $spec: expected ADDR:PORT (the port is required)"
  fi
  if ! [[ $SERVE_PORT =~ ^[1-9][0-9]{0,4}$ ]] || (( SERVE_PORT > 65535 )); then
    die "--serve $spec: the port must be a number from 1 to 65535"
  fi
  addrs=$(resolve_addrs "$host") || addrs=""
  [[ -n $addrs ]] || die "--serve: $host does not resolve to an address (getent ahosts)"
  have ip || die "--serve: no ip command, so this host's addresses cannot be listed"
  while read -r _ _ _ a _; do mine[${a%/*}]=1; done < <(ip -o addr show 2>/dev/null)
  SERVE_ADDR=""
  while IFS= read -r a; do [[ -n ${mine[$a]:-} ]] && { SERVE_ADDR=$a; break; }; done <<< "$addrs"
  if [[ -z $SERVE_ADDR ]]; then
    [[ $addrs == "$host" ]] || host+=" (${addrs//$'\n'/ })"
    die "--serve: $host is not an address on this host"
  fi
  [[ ${SERVE_ADDR,,} != fe80:* ]] || die "--serve: $SERVE_ADDR is link-local; use an address with global or site scope"
  [[ $host == "$SERVE_ADDR" ]] || SERVE_NAME=$host
  if [[ $SERVE_ADDR == *:* ]]; then SERVE_BIND="[$SERVE_ADDR]:$SERVE_PORT"; else SERVE_BIND="$SERVE_ADDR:$SERVE_PORT"; fi

  host_busybox || die "--serve needs busybox (its httpd serves the receipt from the tmpfs); none found"
  bb=$HOST_BB
  [[ $HOST_BB_KIND == static ]] || have ldd \
    || die "--serve: $bb is dynamically linked and there is no ldd to copy its libraries into the tmpfs"
  bb_has httpd "$bb" || die "--serve: $bb has no httpd applet"
  if ! { have systemd-run && [[ -d /run/systemd/system ]]; }; then
    bb_has timeout "$bb" || die "--serve without systemd needs busybox's timeout applet; $bb has none"
  fi
  if have systemctl && systemctl is-active --quiet "$SERVE_UNIT" 2>/dev/null; then
    die "--serve: systemd unit $SERVE_UNIT is already running (an earlier receipt server?); stop it first: systemctl stop $SERVE_UNIT"
  fi
  if have ss; then
    busy=$(ss -Hltn "sport = :$SERVE_PORT" 2>/dev/null) || busy=""
    [[ -z $busy ]] || die "--serve: TCP port $SERVE_PORT is already in use on this host"
  fi
  SERVE_TOKEN=$(od -An -N16 -tx1 /dev/urandom) || SERVE_TOKEN=""
  SERVE_TOKEN=${SERVE_TOKEN//[[:space:]]/}
  [[ $SERVE_TOKEN =~ ^[0-9a-f]{32}$ ]] || die "--serve: cannot read a random path token from /dev/urandom"
  SERVE_URL="http://$SERVE_BIND/$SERVE_TOKEN/"
  SERVE_FW=$(firewall_note)
  return 0
}

# --send-receipt URL [--send-receipt-header ...]: http(s) only, no
# credentials in the URL, host resolved now and pinned with curl --resolve,
# curl (and for https a CA bundle) present. Before the confirmation.
send_check() {
  local scheme auth rest host port addrs a h n=0 v c v4=() v6=() order=()
  [[ $SEND_URL =~ ^([A-Za-z][A-Za-z0-9+.-]*)://([^/?#]*)(.*)$ ]] \
    || die "--send-receipt: not an http:// or https:// URL"
  scheme=${BASH_REMATCH[1],,} auth=${BASH_REMATCH[2]} rest=${BASH_REMATCH[3]}
  [[ $scheme == http || $scheme == https ]] || die "--send-receipt: only http and https URLs are accepted, not $scheme://"
  [[ $auth != *@* ]] || die "--send-receipt: credentials in the URL are refused (the URL is printed); pass them with --send-receipt-header"
  if [[ $auth =~ ^\[([0-9A-Fa-f:.]+)\](:([0-9]*))?$ ]]; then
    host=${BASH_REMATCH[1]} port=${BASH_REMATCH[3]}
  elif [[ $auth =~ ^([A-Za-z0-9._-]+)(:([0-9]*))?$ ]]; then
    host=${BASH_REMATCH[1]} port=${BASH_REMATCH[3]}
  else
    die "--send-receipt: cannot read the host in the URL"
  fi
  if [[ -z $port ]]; then
    port=80; [[ $scheme == https ]] && port=443
  fi
  if ! [[ $port =~ ^[1-9][0-9]{0,4}$ ]] || (( port > 65535 )); then
    die "--send-receipt: the URL's port must be a number from 1 to 65535"
  fi
  [[ $rest =~ ^[^[:space:][:cntrl:]]*$ ]] || die "--send-receipt: the URL holds spaces or control characters"
  SEND_SHOW="$scheme://$auth${rest%%[?#]*}"
  [[ $rest != *[?#]* ]] || SEND_SHOW+=" (query not shown)"
  addrs=$(resolve_addrs "$host") || addrs=""
  [[ -n $addrs ]] || die "--send-receipt: $host does not resolve to an address (getent ahosts)"
  # Pinned now: once the root fs is gone, resolv.conf and NSS are gone too.
  # Up to 4 addresses, the first of each family up front: the send tries them
  # in turn, so a family that does not work from this host falls back to the
  # other (a real run pinned only ntfy.sh's IPv6 address, and every try
  # failed to connect within 2 ms while IPv4 to the host still worked).
  SEND_PINS=""
  if ! [[ $host =~ ^[0-9.]+$ || $host == *:* ]]; then
    while IFS= read -r a; do
      [[ -n $a ]] || continue
      if [[ $a == *:* ]]; then v6+=("$a"); else v4+=("$a"); fi
    done <<< "$addrs"
    a=${addrs%%$'\n'*}
    if [[ $a == *:* ]]; then
      order=("${v6[0]}"); [[ ${#v4[@]} -eq 0 ]] || order+=("${v4[0]}")
      order+=("${v6[@]:1}" "${v4[@]:1}")
    else
      order=("${v4[0]}"); [[ ${#v6[@]} -eq 0 ]] || order+=("${v6[0]}")
      order+=("${v4[@]:1}" "${v6[@]:1}")
    fi
    for a in "${order[@]:0:4}"; do
      if [[ $a == *:* ]]; then a="[$a]"; fi
      SEND_PINS+="${SEND_PINS:+ }$host:$port:$a"
    done
  fi
  SEND_HNAMES=""
  for h in "${SEND_HDRS[@]}"; do
    n=$(( n + 1 ))
    [[ $h =~ ^([A-Za-z0-9_.-]+):[[:space:]]*[^[:space:]] ]] \
      || die "--send-receipt-header #$n: expected 'Name: value'"
    [[ $h != *[[:cntrl:]]* ]] || die "--send-receipt-header #$n (${BASH_REMATCH[1]}): control characters in it"
    SEND_HNAMES+="${SEND_HNAMES:+ }${BASH_REMATCH[1]}"
  done
  have curl || die "--send-receipt needs curl (it is copied into the tmpfs with its libraries); not installed"
  v=$(curl -q --version 2>/dev/null) || die "--send-receipt: curl does not run"
  SEND_CA=""
  if [[ $scheme == https ]]; then
    [[ $v =~ Protocols:[^$'\n']*\ https ]] || die "--send-receipt: this curl has no https support"
    for c in "${CA_BUNDLES[@]}"; do [[ -r $c && -s $c ]] && { SEND_CA=$c; break; }; done
    [[ -n $SEND_CA ]] || die "--send-receipt https needs a CA bundle (looked for ${CA_BUNDLES[*]})"
  fi
  SEND_ON=1
  return 0
}

net_check() {
  [[ -z $SERVE_SPEC ]] || serve_check
  [[ -z $SEND_URL ]] || send_check
  return 0
}

# to_console TEXT: put TEXT on the console too, unless stderr already is it.
to_console() {
  local con=""
  if [[ -w /dev/console ]]; then con=/dev/console
  elif [[ -w /dev/ttyS0 ]]; then con=/dev/ttyS0; fi
  if [[ -n $con ]] && ! [[ /proc/self/fd/2 -ef $con ]]; then
    { printf '%s\n' "$1" > "$con"; } 2>/dev/null || return 1
  fi
  return 0
}

# Before the confirmation, so the operator has the URL even if SSH dies.
net_announce() {
  local m lines=()
  if [[ -n $SERVE_TOKEN ]]; then
    lines+=("RECEIPT URL: $SERVE_URL${SERVE_NAME:+  ($SERVE_NAME = $SERVE_ADDR)}"
          "  serves RECEIPT.txt, RECEIPT.txt.sha256 and VERDICT once written, slag.log live; plain HTTP"
          "  for ${SERVE_SECS}s or until power-off. It shows the host name, disk serials and file paths.")
  fi
  [[ -z $SEND_ON ]] || lines+=("the receipt will be sent once: POST $SEND_SHOW${SEND_HNAMES:+ (headers: $SEND_HNAMES)}")
  [[ ${#lines[@]} -gt 0 ]] || return 0
  for m in "${lines[@]}"; do log "$m"; done
  [[ -z $SERVE_FW ]] || warn "a host firewall may block TCP port $SERVE_PORT ($SERVE_FW); slag.sh does not change it"
  if [[ $DRY_RUN -eq 0 ]]; then
    printf -v m '[slag] %s\n' "${lines[@]}"
    to_console "${m%$'\n'}" || true
  fi
  return 0
}

# cfq S: S as a double-quoted curl config value.
cfq() {
  local s=${1//\\/\\\\}
  s=${s//\"/\\\"}
  printf '"%s"' "$s"
}

# Into the tmpfs, next to the tools: curl, the CA bundle and the send config
# (URL and headers in a root-only file, never on a command line), and the
# served directory with the live log hard-linked into it. net.vars carries
# the rest to the detached run.
stage_net() {
  local ram="$1" src h v www
  [[ -n $ram && -d $ram ]] || die "stage_net: no tmpfs directory"
  if [[ -n $SEND_ON ]]; then
    src=$(type -P curl) || die "curl disappeared"
    copy_tool "$ram" curl "$src"
    : > "$ram/openssl.cnf" || die "cannot write $ram/openssl.cnf"
    if [[ -n $SEND_CA ]]; then cp -L -- "$SEND_CA" "$ram/ca.crt" || die "cannot copy $SEND_CA into $ram"; fi
    ( umask 077
      { printf 'url = %s\n' "$(cfq "$SEND_URL")"
        for h in "${SEND_HDRS[@]}"; do printf 'header = %s\n' "$(cfq "$h")"; done
      } > "$ram/send.conf" ) || die "cannot write $ram/send.conf"
    # ip only explains a failed connect (the route the kernel has then).
    if src=$(type -P ip); then copy_tool "$ram" ip "$src"; fi
    log "send-receipt: curl$([[ -n $SEND_CA ]] && echo " and $SEND_CA") staged; POST $SEND_SHOW${SEND_PINS:+, pinned as ${SEND_PINS// /, }}"
    for h in $SEND_PINS; do
      v=$(pin_addr "$h")
      log "send-receipt: route to $v now: $(route_desc "$v")"
    done
  fi
  if [[ -n $SERVE_TOKEN ]]; then
    if ! ram_bb "$ram" || ! bb_has httpd "${RAM_BB[@]}"; then die "busybox httpd was not staged into $ram"; fi
    www=$ram/www
    mkdir -p "$www/$SERVE_TOKEN" || die "cannot create $www/$SERVE_TOKEN"
    # Empty index pages: a directory request gets an empty page, not a listing.
    : > "$www/index.html" && : > "$www/$SERVE_TOKEN/index.html" && : > "$ram/httpd.conf" \
      || die "cannot write the served directory in $ram"
    : > "$ram/slag.log" && ln -- "$ram/slag.log" "$www/$SERVE_TOKEN/slag.log" \
      || die "cannot link slag.log into $www/$SERVE_TOKEN"
  fi
  { for v in SERVE_ADDR SERVE_PORT SERVE_BIND SERVE_URL SERVE_TOKEN SERVE_NAME SERVE_FW SERVE_SECS \
             SEND_ON SEND_SHOW SEND_PINS SEND_HNAMES; do
      printf '%s=%q\n' "$v" "${!v}"
    done; } > "$ram/net.vars" || die "cannot write $ram/net.vars"
  return 0
}

# The detached run takes the --serve/--send-receipt settings from the tmpfs.
net_load() {
  local f="${SLAG_RAM:-}/net.vars"
  [[ -n ${SLAG_RAM:-} && -f $f ]] || return 0
  # shellcheck source=/dev/null
  source "$f"
}

# Before quiesce: busybox httpd from the tmpfs, in its own systemd unit (or
# under setsid), so it outlives the wipe; RuntimeMaxSec / timeout ends it.
serve_start() {
  local ram="${SLAG_RAM:-/run/slag.TMPFS}" out rc=0 pid how probe="" fw="" bbok=1
  [[ -n $SERVE_TOKEN ]] || return 0
  ram_bb "$ram" || { bbok=0; RAM_BB=("$ram/bb/busybox"); }
  local httpd=("${RAM_BB[@]}" httpd -f -p "$SERVE_BIND" -h "$ram/www" -c "$ram/httpd.conf")
  local sr=(systemd-run --unit "$SERVE_UNIT" --collect --quiet -p "RuntimeMaxSec=$SERVE_SECS"
            -p StandardInput=null -p StandardOutput=null)
  [[ -z $SERVE_FW ]] || fw="; a host firewall may block port $SERVE_PORT ($SERVE_FW), not changed"
  if [[ $DRY_RUN -eq 1 ]]; then
    run "${sr[@]}" -- "${httpd[@]}"
    record host serve ok "would serve $SERVE_URL until power-off or ${SERVE_SECS}s$fw"
    return 0
  fi
  if [[ $bbok -eq 0 || ! -d $ram/www/$SERVE_TOKEN ]]; then
    record host serve failed "busybox or the served directory is missing from the tmpfs"
    return 0
  fi
  if have systemd-run && [[ -d /run/systemd/system ]]; then
    out=$(run "${sr[@]}" -- "${httpd[@]}" 2>&1 </dev/null) || rc=$?
    if [[ $rc -ne 0 ]]; then
      record host serve failed "systemd-run exit $rc: $(lastline "$out")"
      return 0
    fi
    sleep 1
    if ! systemctl is-active --quiet "$SERVE_UNIT" 2>/dev/null; then
      out=$(journalctl -u "$SERVE_UNIT" -n 5 --no-pager -o cat 2>/dev/null) || out=""
      record host serve failed "httpd did not stay up (address or port taken?)${out:+: $(lastline "$out")}"
      return 0
    fi
    how="systemd unit $SERVE_UNIT, RuntimeMaxSec=$SERVE_SECS"
  else
    _tolog '[exec]' "setsid busybox timeout $SERVE_SECS ${httpd[*]}"
    setsid "${RAM_BB[@]}" timeout "$SERVE_SECS" "${httpd[@]}" </dev/null >/dev/null 2>&1 &
    pid=$!
    sleep 1
    if ! kill -0 "$pid" 2>/dev/null; then
      record host serve failed "httpd exited at once (address or port taken?)"
      return 0
    fi
    how="pid $pid under busybox timeout ${SERVE_SECS}s"
  fi
  if bb_has wget "${RAM_BB[@]}"; then
    if "${RAM_BB[@]}" wget -q -T 5 -O /dev/null "$SERVE_URL" 2>/dev/null </dev/null; then
      probe="; a local fetch of the URL worked"
    else
      probe="; a local fetch of the URL FAILED"
    fi
  fi
  record host serve ok "$SERVE_URL until power-off or ${SERVE_SECS}s ($how)$probe$fw"
  [[ -z $SERVE_FW ]] || warn "a host firewall may block TCP port $SERVE_PORT ($SERVE_FW); slag.sh does not change it"
  return 0
}

# RECEIPT.txt.sha256 next to RECEIPT.txt; RECEIPT_SHA for the console.
receipt_hash() {
  local f="${SLAG_RAM:-}/RECEIPT.txt" out
  RECEIPT_SHA=""
  [[ -n ${SLAG_RAM:-} && -s $f ]] && have sha256sum || return 0
  out=$(sha256sum < "$f" 2>/dev/null) || return 0
  out=${out%% *}
  [[ $out =~ ^[0-9a-f]{64}$ ]] || return 0
  RECEIPT_SHA=$out
  { printf '%s  RECEIPT.txt\n' "$out" > "$SLAG_RAM/RECEIPT.txt.sha256"; } 2>/dev/null || true
  return 0
}

# Copy the receipt, its hash and the verdict into the served directory.
serve_publish() {
  local d f
  [[ -n $SERVE_TOKEN && -n ${SLAG_RAM:-} ]] || return 0
  d=$SLAG_RAM/www/$SERVE_TOKEN
  [[ -d $d ]] || return 0
  for f in RECEIPT.txt RECEIPT.txt.sha256 VERDICT; do
    [[ -f $SLAG_RAM/$f ]] || continue
    dd if="$SLAG_RAM/$f" of="$d/$f" status=none 2>/dev/null </dev/null \
      || warn "could not copy $f into the served directory"
  done
  return 0
}

# pin_addr HOST:PORT:ADDR: the address of a --resolve pin, without brackets.
pin_addr() {
  local a=${1#*:}
  a=${a#*:}; a=${a#[}; a=${a%]}
  printf '%s' "$a"
}

# route_desc ADDR: the kernel's route to ADDR right now (or why there is
# none), on one line. Logged before the wipe and with a failed connect.
route_desc() {
  local out words=()
  have ip || { printf 'ip not available'; return 0; }
  out=$(ip -o route get "$1" 2>&1 </dev/null) || true
  out=${out%%$'\n'*}
  read -r -a words <<< "${out//\\/ }" || true
  out="${words[*]}"
  printf '%s' "${out:0:160}"
}

# POST the receipt once, as written; then append the outcome to the local
# copy, after its closing line. What was sent is the local copy minus that
# last line. Each try goes through the pinned addresses in turn until one
# answers. A failed send never changes the verdict.
send_receipt() {
  local ram="${SLAG_RAM:-}" f try rc out code why="" st=failed det wait=2 args=() pins=() rargs=() pin via stop
  local whys=""
  [[ -n $SEND_ON ]] || return 0
  if [[ $DRY_RUN -eq 1 ]]; then
    record host send-receipt ok "would POST the receipt to $SEND_SHOW${SEND_HNAMES:+ with headers: $SEND_HNAMES}"
    return 0
  fi
  f=$ram/RECEIPT.txt
  if [[ -z $ram || ! -s $f || ! -f $ram/send.conf ]]; then
    det="no receipt or no send config in the tmpfs; nothing sent"
  elif ! have curl; then
    det="curl is not available in the tmpfs; nothing sent"
  else
    args=(-q -sS -o "$ram/send.out" -w '%{http_code}' -K "$ram/send.conf" --proto "=http,https"
          --max-redirs 0 --connect-timeout 10 --max-time 60 -H 'Content-Type: text/plain' --data-binary "@$f")
    [[ -f $ram/ca.crt ]] && args+=(--cacert "$ram/ca.crt")
    read -r -a pins <<< "$SEND_PINS" || true
    [[ ${#pins[@]} -gt 0 ]] || pins=("")
    export OPENSSL_CONF="$ram/openssl.cnf"
    log "sending the receipt: POST $SEND_SHOW${SEND_HNAMES:+ (headers: $SEND_HNAMES)}"
    for try in 1 2 3 4; do
      whys="" stop=0
      for pin in "${pins[@]}"; do
        rc=0 rargs=() via=""
        [[ -z $pin ]] || { rargs=(--resolve "$pin"); via=$(pin_addr "$pin"); }
        out=$(run curl "${args[@]}" "${rargs[@]}" 2> "$ram/send.err" </dev/null) || rc=$?
        code=${out: -3}
        if [[ $rc -eq 0 && $code =~ ^2[0-9][0-9]$ ]]; then
          st=ok
          det="HTTP $code from POST $SEND_SHOW (try $try of 4${via:+, via $via}"
          [[ -z $whys ]] || det+=", after $whys"
          det+="${SEND_HNAMES:+; headers: $SEND_HNAMES}); the body was the receipt above this line"
          break 2
        fi
        if [[ $rc -ne 0 ]]; then
          out=""; { out=$(< "$ram/send.err"); } 2>/dev/null || true
          why="curl exit $rc: $(lastline "$out")"
          # Could not connect (7) or timed out (28): say what the route was.
          [[ -n $via && ( $rc -eq 7 || $rc -eq 28 ) ]] && why+=" (route: $(route_desc "$via"))"
        else
          why="HTTP $code"
        fi
        whys+="${whys:+; }${via:+$via: }$why"
        # A 4xx other than a timeout or rate limit will not change on a retry.
        [[ $rc -eq 0 && $code =~ ^4 && $code != 408 && $code != 429 ]] && { stop=1; break; }
      done
      [[ $stop -eq 0 ]] || break
      if (( try < 4 )); then
        log "send-receipt: try $try failed ($whys); retrying in ${wait}s"
        sleep "$wait"; wait=$(( wait * 2 ))
      fi
    done
    [[ $st == ok ]] || det="not delivered after $try tries to POST $SEND_SHOW; last: $whys"
  fi
  record host send-receipt "$st" "$det"
  { printf '%-24s %-12s %s\n' send-receipt "$st" "${det//[$'\t\n\r']/ }" >> "$f"; } 2>/dev/null \
    || warn "could not append the send outcome to $f"
  return 0
}

# ---- tmpfs staging ---------------------------------------------------------

# Real copies go in $ram/bin (libraries + loader in $ram/lib), busybox applet
# links in $ram/bb. Separate dirs, so no copy can land on an applet symlink and
# write through it into busybox.
copy_lib() {
  local dst="$1/lib/${2##*/}"
  [[ -e $dst ]] || cp -L -- "$2" "$dst" || die "cannot copy library $2 into tmpfs"
}

copy_tool() {
  local ram="$1" name="$2" src="$3" out a b c _r dyn=0
  cp -L -- "$src" "$ram/bin/$name" || die "cannot copy $src into tmpfs"
  if ! have ldd; then
    warn "no ldd: $name may still load shared libraries from the root fs"
    return 0
  fi
  # ldd exits nonzero for static binaries; the output says which it is.
  out=$(ldd "$src" 2>&1) || true
  while read -r a b c _r; do
    if [[ $b == "=>" && $c == /* ]]; then
      copy_lib "$ram" "$c"; dyn=1
    elif [[ $b == "=>" && $c == not ]]; then
      warn "$name: shared library $a not found on this host"
    elif [[ $a == /* ]]; then
      copy_lib "$ram" "$a"; printf '%s\n' "${a##*/}" > "$ram/loader"; dyn=1
    fi
  done <<< "$out"
  [[ $dyn -eq 1 ]] && printf '%s\n' "$name" >> "$ram/dynamic"
  return 0
}

stage_tools() {
  local ram="$1" t src missing=() out
  log "staging the wipe's tools into $ram"
  mkdir -p "$ram/bin" "$ram/lib" "$ram/bb" || die "cannot create $ram/{bin,lib,bb}"
  : > "$ram/dynamic"
  cp -- "$(realpath -- "${BASH_SOURCE[0]}")" "$ram/slag.sh" || die "cannot copy $PROG into $ram"
  # A static bash needs no loader at all; use one if the host has it.
  src=$(type -P bash-static) || src=$BASH
  copy_tool "$ram" bash "$src"
  for t in "${WIPE_TOOLS[@]}"; do
    if src=$(type -P "$t"); then copy_tool "$ram" "$t" "$src"; else missing+=("$t"); fi
  done
  if host_busybox; then
    if [[ $HOST_BB_KIND == dynamic ]]; then
      # Run only through the tmpfs loader (--serve); applet links would load
      # the system's libraries, so it backs up no missing tool.
      log "busybox is dynamically linked: staged with its libraries for --serve, not as a fallback for missing tools"
      copy_tool "$ram" busybox "$HOST_BB"
    else
      cp -L -- "$HOST_BB" "$ram/bb/busybox" || die "cannot copy busybox into tmpfs"
      for t in "${BB_APPLETS[@]}"; do ln -s busybox "$ram/bb/$t" || die "cannot link busybox applet $t"; done
    fi
  fi
  [[ ${#missing[@]} -eq 0 ]] || warn "not installed (steps needing them record 'unsupported'): ${missing[*]}"
  return 0
}

# The file-extent proof's path list goes into tmpfs with the tools.
stage_inventory() {
  local src="$OUTDIR/meta/paths.txt"
  [[ $OUTDIR_SET -eq 1 ]] || { log "no --out/SLAG_OUTDIR: the file-extent proof uses the built-in list only"; return 0; }
  if [[ -r $src ]]; then
    cp -- "$src" "$1/inv-paths.txt" || die "cannot copy $src into $1"
    log "file-extent proof: inventory paths from $src"
  else
    warn "no $src: the file-extent proof uses the built-in list only"
  fi
  return 0
}

ram_is_dynamic() {
  local n
  [[ -f $1/dynamic ]] || return 1
  while IFS= read -r n; do [[ $n == "$2" ]] && return 0; done < "$1/dynamic"
  return 1
}

# INTERP runs the tmpfs bash; a dynamic one goes through the tmpfs loader,
# because its PT_INTERP and libraries otherwise come from the root fs.
ram_interp() {
  local ram="$1" ld
  if ram_is_dynamic "$ram" bash; then
    read -r ld < "$ram/loader"; ld_flags "$ld"
    INTERP=("$ram/lib/$ld" "${LDF[@]}" --library-path "$ram/lib" "$ram/bin/bash")
  else
    INTERP=("$ram/bin/bash")
  fi
}

# Shadow each dynamic tool with a function that runs the tmpfs copy through
# the tmpfs loader, so `dd ...` in this script never touches the root fs.
ram_shadow_tools() {
  local ram="$1" ld t
  RAM_DIR=$ram
  [[ -s $ram/dynamic && -f $ram/loader ]] || return 0
  read -r ld < "$ram/loader"
  RAM_LD="$ram/lib/$ld"
  ld_flags "$ld"; RAM_LDF=("${LDF[@]}")
  while IFS= read -r t; do
    [[ $t != bash && $t =~ ^[A-Za-z0-9_-]+$ ]] || continue
    eval "$t() { \"\$RAM_LD\" \"\${RAM_LDF[@]}\" --library-path \"\$RAM_DIR/lib\" \"\$RAM_DIR/bin/$t\" \"\$@\"; }"
  done < "$ram/dynamic"
}

# Outer half: run the staged copy of this script, with the staged bash, as
# the self-test. Nothing here touches a disk.
selftest_tools() {
  local ram="$1" rc=0
  ram_interp "$ram"
  log "self-test: running the staged bash, script and tools from $ram"
  LC_ALL=C TZ=UTC0 TZDIR="$ram/zoneinfo" SLAG_STAGE=selftest SLAG_RAM="$ram" SLAG_LOG="" \
    SLAG_NEED_HTTPD="${SERVE_TOKEN:+1}" SLAG_NEED_CURL="$SEND_ON" \
    "${INTERP[@]}" "$ram/slag.sh" wipe || rc=$?
  return "$rc"
}

# Inner half, running from tmpfs with no root-fs directory on PATH.
selftest_inner() {
  local ram="$SLAG_RAM" t out a b c _r fail=0 deps=0 kind
  PATH="$ram/bin:$ram/bb"
  ram_shadow_tools "$ram"
  log "self-test: bash $BASH_VERSION running from $ram"

  for t in "${CORE_TOOLS[@]}"; do
    if ! have "$t"; then err "self-test: core tool $t is missing from tmpfs"; fail=1; fi
  done
  # The zero check pipes up to 16 MiB into `cmp -n LEN - /dev/zero` and needs
  # it to stop at the first difference with exit 1 (GNU). BusyBox cmp reports
  # every differing byte instead: millions of lines per chunk.
  a=0; out=$(printf 'xy' | cmp -n 2 - /dev/zero 2>&1) || a=$?
  if [[ $a -ne 1 || $out == *$'\n'* ]]; then
    err "self-test: cmp does not stop at the first difference (exit $a; GNU diffutils cmp needed)"; fail=1
  fi
  if [[ -s $ram/dynamic ]]; then
    while IFS= read -r t; do
      out=$("$RAM_LD" "${RAM_LDF[@]}" --library-path "$ram/lib" --list "$ram/bin/$t" 2>&1) \
        || { err "self-test: the tmpfs loader cannot load $t: $(lastline "$out")"; fail=1; continue; }
      while read -r a b c _r; do
        [[ $b == "=>" ]] || continue
        [[ $c == "$ram/lib/"* ]] && continue
        # musl: libc IS the loader. --list names it by its build path, but the
        # code is the tmpfs loader already running this listing.
        [[ $RAM_LD == */ld-musl-* && $a == libc.musl-* && $c == */ld-musl-* ]] && continue
        if [[ " ${CORE_TOOLS[*]} " == *" $t "* ]] || [[ $t == curl && -n ${SLAG_NEED_CURL:-} ]]; then
          err "self-test: $t needs $a from outside tmpfs ($c)"; fail=1
        else
          warn "self-test: $t needs $a from the root fs ($c)"; deps=1
        fi
      done <<< "$out"
    done < "$ram/dynamic"
  fi
  if ram_bb "$ram"; then
    kind="static busybox"
    [[ ${#RAM_BB[@]} -eq 1 ]] || kind="busybox (dynamic, run through the tmpfs loader)"
    out=$("${RAM_BB[@]}" dd if=/dev/zero bs=512 count=1 2>/dev/null | "${RAM_BB[@]}" wc -c) || out=
    if [[ ${out//[[:space:]]/} == 512 ]]; then log "self-test: $kind ok"
    elif [[ ${#RAM_BB[@]} -eq 1 || -n ${SLAG_NEED_HTTPD:-} ]]; then err "self-test: $kind in tmpfs does not run (got '$out')"; fail=1
    else warn "self-test: $kind in tmpfs does not run (got '$out'); --serve is unavailable"; fi
    if bb_has httpd "${RAM_BB[@]}"; then log "self-test: busybox has httpd (--serve)"
    elif [[ -n ${SLAG_NEED_HTTPD:-} ]]; then err "self-test: busybox in tmpfs has no httpd applet (--serve)"; fail=1
    else warn "self-test: busybox has no httpd applet (--serve is unavailable)"; fi
    [[ ${#RAM_BB[@]} -eq 1 ]] || warn "self-test: no static busybox staged (no fallback for missing tools)"
  else
    warn "self-test: no static busybox staged (no fallback for missing tools)"
    [[ -z ${SLAG_NEED_HTTPD:-} ]] || { err "self-test: --serve needs busybox httpd in tmpfs"; fail=1; }
  fi
  if [[ -n ${SLAG_NEED_CURL:-} ]]; then
    if have curl && out=$(curl -q --version 2>&1); then log "self-test: curl runs from tmpfs (${out%%$'\n'*})"
    else err "self-test: curl does not run from tmpfs (--send-receipt): $(lastline "${out:-not staged}")"; fail=1; fi
  fi
  out=$(printf abc | sha256sum 2>/dev/null) || out=
  if [[ ${out%% *} == ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad ]]; then
    log "self-test: sha256sum ok"
  else
    warn "self-test: sha256sum does not work from tmpfs; the receipt gets no SHA-256"
  fi

  # The verify primitive itself, exactly as the wipe calls it: exact length of
  # zeros passes; a short read fails; one nonzero byte fails at its offset.
  if [[ $fail -eq 0 ]]; then
    dd if=/dev/zero of="$ram/st.short" bs=1000 count=500 status=none
    { dd if=/dev/zero bs=1000 count=999 status=none; printf 'x'; } > "$ram/st.dirty"
    if ! zero_read /dev/zero 0 1000000 ""; then
      err "self-test: 1000000 zero bytes did not verify (dd=$ZR_DD cmp=$ZR_CMP: $ZR_MSG)"; fail=1
    fi
    if zero_read "$ram/st.short" 0 1000000 ""; then
      err "self-test: a short read verified as zero"; fail=1
    fi
    if zero_read "$ram/st.dirty" 0 999001 ""; then
      err "self-test: data with a nonzero byte verified as zero"; fail=1
    elif [[ $(zr_reason 0) != *"offset 999000" ]]; then
      err "self-test: nonzero byte reported wrongly: $(zr_reason 0)"; fail=1
    fi
    if ! zero_read "$ram/st.dirty" 0 999000 ""; then
      err "self-test: a zero prefix did not verify"; fail=1
    fi
    if zero_read "$ram/st.dirty" 999000 1 ""; then
      err "self-test: an offset read missed the nonzero byte"; fail=1
    fi
    out=$(od -An -N8 -tu8 /dev/urandom) || out=
    [[ ${out//[[:space:]]/} =~ ^[0-9]+$ ]] || { err "self-test: od cannot read 64-bit random numbers"; fail=1; }
    blockdev --help >/dev/null 2>&1 || { err "self-test: blockdev does not run"; fail=1; }
    dd if=/dev/zero of=/dev/null bs=512 count=1 status=progress 2>/dev/null \
      || { err "self-test: dd does not accept status=progress"; fail=1; }
    selftest_zeroize || fail=1
    selftest_strike || fail=1
    selftest_pattern || fail=1
    selftest_filefrag || fail=1
  fi
  for t in filefrag find blkdiscard; do
    have "$t" || warn "self-test: $t is not staged (file-extent proof / --reclaim record 'unsupported')"
  done

  [[ $deps -eq 0 ]] || warn "self-test: some non-core tools still depend on the root fs (above)"
  if [[ $fail -ne 0 ]]; then err "self-test FAILED"; exit 1; fi
  good "self-test passed: bash, dd, cmp, od, blockdev run from tmpfs; verify catches short reads and nonzero bytes; zeroize writes exactly the nonzero 64 KiB blocks; the strike zeroes exactly the blocks holding key signatures; overwrite patterns write every byte and read back by SHA-256; filefrag output maps to disk offsets"
  exit 0
}

# Zeroize, exactly as the wipe runs it, on a tmpfs file (buffered: tmpfs may
# not take O_DIRECT): islands in the first sub-block, across the chunk
# boundary, and in the short last sub-block. Only those blocks may be written.
selftest_zeroize() {
  local f="$SLAG_RAM/st.norm" sz=$(( 16 * 1024 * 1024 + 200000 )) expect=$(( 3 * 65536 + 3392 )) ok=0 RES=()
  dd if=/dev/zero of="$f" bs=1 count=0 seek="$sz" status=none || return 1
  { printf 'island-one' | dd of="$f" bs=1 seek=4096 conv=notrunc status=none &&
    dd if=/dev/urandom of="$f" bs=4096 count=1 oflag=seek_bytes seek=$(( 16 * 1024 * 1024 - 2048 )) conv=notrunc status=none &&
    printf 'tail' | dd of="$f" bs=1 seek=$(( sz - 4 )) conv=notrunc status=none; } || { err "self-test: cannot build $f"; return 1; }
  if NORM_IFLAG="" NORM_OFLAG="" zeroize_device "$f" "$sz" 2>/dev/null \
     && [[ $NORM_WRITTEN == "$expect" && $NORM_RUNS == 4 && $NORM_READ == "$sz" ]] && zero_read "$f" 0 "$sz" ""; then
    ok=1
  fi
  : > "$f"
  [[ $ok -eq 1 ]] && return 0
  err "self-test: zeroize wrote $NORM_WRITTEN bytes in $NORM_RUNS writes (want $expect in 4), read $NORM_READ of $sz; ${NORM_RERR1:-${NORM_WERR1:-no error}}"
  return 1
}

# The strike, exactly as the wipe runs it, on a tmpfs file: an OpenSSH key,
# a shadow line across the 16 MiB chunk edge (found through the overlap, its
# tail in the next block), a LUKS header (16 MiB span) and an AWS key id in
# the short last block. Only their blocks may change; two plain-text blocks
# must survive (one holds the LUKS magic off a 4 KiB boundary, as in
# cryptsetup's library), and no other signature may remain anywhere.
selftest_strike() {
  local f="$SLAG_RAM/st.strike" M=$(( 1024 * 1024 )) sz ok=1 why="" r a e pad
  local LC_ALL=C
  printf -v pad '%4096s' ''   # tr is not staged in the tmpfs
  sz=$(( 40 * M + 300000 ))
  dd if=/dev/zero of="$f" bs=1 count=0 seek="$sz" status=none || return 1
  { printf '%s' "${pad// /x}" | dd of="$f" bs=1 seek=$(( 1 * M )) conv=notrunc status=none &&
    printf -- '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmU=\n' | dd of="$f" bs=1 seek=$(( 3 * M + 100 )) conv=notrunc status=none &&
    printf 'root:$6$saltsalt$hashhashhashhashhash:19000:0:99999:7:::\n' | dd of="$f" bs=1 seek=$(( 16 * M - 10 )) conv=notrunc status=none &&
    printf 'LUKS\xba\xbe\x00\x02' | dd of="$f" bs=1 seek=$(( 20 * M )) conv=notrunc status=none &&
    printf 'LUKS\xba\xbe\x00tem' | dd of="$f" bs=1 seek=$(( 1 * M + 110 )) conv=notrunc status=none &&
    printf '%s' "${pad// /y}" | dd of="$f" bs=1 seek=$(( 38 * M )) conv=notrunc status=none &&
    printf 'AKIAABCDEFGHIJKLMNOP' | dd of="$f" bs=1 seek=$(( sz - 40 )) conv=notrunc status=none
  } || { err "self-test: cannot build $f"; return 1; }
  strike_load
  if ! NORM_IFLAG="" NORM_OFLAG="" strike_device "$f" "$sz" 2>/dev/null; then ok=0; why="strike_device failed: ${DEV_STRIKE[$f]:-}"; fi
  # Struck: [3M,3M+128K) [16M-64K,16M+64K) [20M,36M) [last block start,sz)
  for r in "$(( 3 * M )) $(( 3 * M + 131072 ))" "$(( 16 * M - 65536 )) $(( 16 * M + 65536 ))" \
           "$(( 20 * M )) $(( 36 * M ))" "$(( (sz - 40) / 65536 * 65536 )) $sz"; do
    read -r a e <<< "$r"
    zero_read "$f" "$a" $(( e - a )) "" || { ok=0; why+="; [$a,$e) not zero: $(zr_reason "$a")"; }
  done
  for a in $(( 1 * M )) $(( 38 * M )); do
    zero_read "$f" "$a" 4096 "" && { ok=0; why+="; plain block at $a was zeroed"; }
  done
  [[ $ST_WRITTEN == 300000 ]] || { ok=0; why+="; wrote $ST_WRITTEN bytes (want 4 x 64 KiB + the 37856-byte last block: only the nonzero blocks)"; }
  (( ST_HITS >= 4 )) || { ok=0; why+="; $ST_HITS hits (want at least 4)"; }
  for r in pem-private shadow-hash luks-header aws-key; do
    [[ ${DEV_STRIKE[$f]:-} == *"$r "* ]] || { ok=0; why+="; no $r hit"; }
  done
  # Exactly one signature may remain: the LUKS magic off a 4 KiB boundary.
  r=$(grep -a -o -E -f "$SLAG_RAM/strike.re" -- "$f" | wc -l)
  [[ $r == 1 && $ST_UNALIGNED == 1 ]] || { ok=0; why+="; $r signatures left (want 1: the unaligned LUKS magic), $ST_UNALIGNED unaligned"; }
  : > "$f"
  [[ $ok -eq 1 ]] && return 0
  err "self-test: strike: ${why#; }"
  return 1
}

# The overwrite patterns, exactly as the wipe runs them, on a tmpfs file whose
# size is no multiple of the 3 MiB buffer: a fixed 3-byte last pass must leave
# exactly that pattern, in phase, on every byte; a random last pass must read
# back with the SHA-256 that was written; a byte changed after the write must
# fail the read-back.
selftest_pattern() {
  local f="$SLAG_RAM/st.pat" sz=$(( 7 * 1048576 + 5 )) ok=1 why="" want got
  local PATTERN=selftest PAT_STD="self-test" PAT_PASSES=()
  dd if=/dev/zero of="$f" bs=1 count=0 seek="$sz" status=none || return 1
  PAT_PASSES=(R 6DB6DB)
  if NORM_IFLAG="" NORM_OFLAG="" pattern_device "$f" "$sz" 2>/dev/null; then
    pat_buf 6DB6DB || { ok=0; why+="; cannot build the 6DB6DB buffer"; }
    want=$(pat_stream 6DB6DB | head -c "$sz" | sha256sum); want=${want%% *}
    got=$(sha256sum < "$f"); got=${got%% *}
    [[ $got == "$want" && ${DEV_PATSUM[$f]:-} == "$want" ]] \
      || { ok=0; why+="; the 6DB6DB pass left sha256 $got (want $want; recorded ${DEV_PATSUM[$f]:-none})"; }
  else
    ok=0; why+="; R 6DB6DB: ${DEV_PATTERN[$f]:-}"
  fi
  PAT_PASSES=(00 R)
  if NORM_IFLAG="" NORM_OFLAG="" pattern_device "$f" "$sz" 2>/dev/null; then
    zero_read "$f" 0 "$sz" "" && { ok=0; why+="; the random pass left the file zero"; }
    want=${DEV_PATSUM[$f]:-}
    # Flip the byte (255 - b): a fixed value would equal the random byte 1 time in 256.
    local b; b=$(od -An -v -tu1 -j $(( sz - 3 )) -N1 "$f" | tr -d ' \n')
    # shellcheck disable=SC2059
    printf "$(printf '\\x%02x' $(( 255 - ${b:-0} )))" | dd of="$f" bs=1 seek=$(( sz - 3 )) conv=notrunc status=none
    got=$(NORM_IFLAG="" pat_readsum "$f" "$sz") || got=""
    [[ -n $got && $got != "$want" ]] || { ok=0; why+="; a byte changed after the write still read back as $want"; }
  else
    ok=0; why+="; 00 R: ${DEV_PATTERN[$f]:-}"
  fi
  # INV: the pass must leave exactly the complement of what was there.
  PAT_PASSES=(R)
  if NORM_IFLAG="" NORM_OFLAG="" pattern_device "$f" "$sz" 2>/dev/null && cat "$f" > "$f.r"; then
    PAT_PASSES=(INV)
    if NORM_IFLAG="" NORM_OFLAG="" pattern_device "$f" "$sz" 2>/dev/null; then
      want=$(LC_ALL=C tr "$PAT_SET" "$PAT_INV" < "$f.r" | sha256sum); want=${want%% *}
      got=$(sha256sum < "$f"); got=${got%% *}
      [[ $got == "$want" && ${DEV_PATSUM[$f]:-} == "$want" ]] \
        || { ok=0; why+="; INV left sha256 $got, not the complement of the random pass ($want)"; }
      # Independently of the tr maps: bytes at the start and the end must each
      # be 255 minus the random byte that was there.
      local o x=() y=() k
      for o in 0 $(( sz - 64 )); do
        read -r -a x <<< "$(od -An -v -tu1 -j "$o" -N64 "$f.r" | tr -s ' \n' '  ')"
        read -r -a y <<< "$(od -An -v -tu1 -j "$o" -N64 "$f" | tr -s ' \n' '  ')"
        [[ ${#x[@]} -eq 64 && ${#y[@]} -eq 64 ]] || { ok=0; why+="; INV: cannot read bytes at $o"; break; }
        for (( k = 0; k < 64; k++ )); do
          (( x[k] + y[k] == 255 )) || { ok=0; why+="; INV: byte $(( o + k )) is ${y[k]}, the random pass had ${x[k]}"; break 2; }
        done
      done
    else
      ok=0; why+="; INV: ${DEV_PATTERN[$f]:-}"
    fi
  else
    ok=0; why+="; R before INV: ${DEV_PATTERN[$f]:-}"
  fi
  : > "$f.r"
  : > "$f"; : > "$SLAG_RAM/pat.buf"
  unset "DEV_PATTERN[$f]" "DEV_PATSUM[$f]"
  [[ $ok -eq 1 ]] && return 0
  err "self-test: pattern: ${why#; }"
  return 1
}

# The filefrag parser on recorded `filefrag -v` output: a partition starting
# at 1 MiB, 4 KiB blocks, one inline extent that has no disk location.
selftest_filefrag() {
  local out="$SLAG_RAM/st.ext" expect got
  declare -gA FM_SEEN=()
  FM_LOC=([/etc/a key]="/dev/sdz 1048576" [/etc/b]="/dev/sdz 1048576") FM_UNMAPPED=() FM_EXTS=0 FM_EMPTY=0
  : > "$out"
  filefrag_parse "$out" <<'FF'
Filesystem type is: ef53
File size of /etc/a key is 300000 (74 blocks of 4096 bytes)
 ext:     logical_offset:        physical_offset: length:   expected: flags:
   0:        0..       9:    1000000..   1000009:     10:
   1:       10..      73:    2000000..   2000063:     64:    1000010: last,eof
/etc/a key: 2 extents found
File size of /etc/b is 60 (1 block of 4096 bytes)
 ext:     logical_offset:        physical_offset: length:   expected: flags:
   0:        0..       0:          0..         0:      1:             last,not_aligned,inline,eof
/etc/b: 1 extent found
File size of /etc/c is 1 (1 block of 4096 bytes)
 ext:     logical_offset:        physical_offset: length:   expected: flags:
   0:        0..       0:        500..       500:      1:             last,eof
/etc/c: 1 extent found
FF
  expect=$'/dev/sdz	4097048576	40960	/etc/a key
/dev/sdz	8193048576	262144	/etc/a key'
  got=$(< "$out")
  : > "$out"
  if [[ $got == "$expect" && $FM_EXTS == 2 && ${#FM_UNMAPPED[@]} == 1 && -n ${FM_SEEN[/etc/b]:-} && -z ${FM_SEEN[/etc/c]:-} ]]; then
    FM_LOC=() FM_UNMAPPED=() FM_SEEN=() FM_EXTS=0
    return 0
  fi
  err "self-test: filefrag parse gave '${got//$'\n'/ | }' ($FM_EXTS extents, ${#FM_UNMAPPED[@]} unmapped reasons)"
  return 1
}

phase_selftest() {
  SELFTEST_DIR=$(mktemp -d "${TMPDIR:-/tmp}/slag-selftest.XXXXXX") || die "mktemp failed"
  trap 'rm -rf -- "$SELFTEST_DIR"' EXIT
  stage_tools "$SELFTEST_DIR"
  selftest_tools "$SELFTEST_DIR" || die "self-test FAILED"
}

# ---- verify primitive -------------------------------------------------------

# zero_read SRC OFF LEN IFLAG [STATUS]: read exactly LEN bytes of SRC from
# byte OFF and compare them with zeros. Passes only when dd read all LEN bytes
# without error AND cmp saw LEN bytes, all zero. cmp -n stops at LEN, so
# comparing against the endless /dev/zero is safe; a short read makes cmp hit
# EOF on stdin and fail. Sets ZR_DD/ZR_CMP (exit codes) and ZR_MSG.
zero_read() {
  local src="$1" off="$2" len="$3" iflag="$4" status="${5:-none}" out rcs
  out=$(dd if="$src" bs=16M iflag="${iflag:+$iflag,}skip_bytes,count_bytes,fullblock" \
          skip="$off" count="$len" status="$status" </dev/null \
        | cmp -n "$len" - /dev/zero 2>&1
        printf '\n%s %s' "${PIPESTATUS[0]}" "${PIPESTATUS[1]}")
  ZR_MSG=${out%$'\n'*}
  rcs=${out##*$'\n'}
  ZR_DD=${rcs%% *}
  ZR_CMP=${rcs##* }
  [[ $ZR_DD == 0 && $ZR_CMP == 0 ]]
}

zr_reason() {
  local off="${1:-0}"
  if [[ $ZR_MSG =~ differ:\ (byte|char)\ ([0-9]+) ]]; then
    printf 'nonzero data at byte offset %s' "$(( off + BASH_REMATCH[2] - 1 ))"
  elif [[ $ZR_MSG == *EOF* ]]; then
    printf 'short read (dd exit %s): %s' "$ZR_DD" "$(lastline "$ZR_MSG")"
  elif [[ $ZR_DD != 0 ]]; then
    printf 'read error (dd exit %s, cmp exit %s)' "$ZR_DD" "$ZR_CMP"
  else
    printf 'cmp exit %s: %s' "$ZR_CMP" "$(lastline "$ZR_MSG")"
  fi
}

# vset DEV PHASE TEXT: the receipt's verify line; the reclaim verify appends.
vset() {
  if [[ $2 == reclaim ]]; then DEV_VERIFY[$1]+="; after reclaim: $3"
  else DEV_VERIFY[$1]=$3; fi
}

# A failed verify after the reclaim discard is reported as what it means.
vwhy() {
  if [[ $1 == reclaim ]]; then printf 'reclaim: device does not read zeros after discard (%s)' "$2"
  else printf '%s' "$2"; fi
}

# verify_full DEV [PHASE]: PHASE "reclaim" is the second verify after --reclaim.
verify_full() {
  local dev="$1" ph="${2:-}" sz why sn=verify
  [[ $ph == reclaim ]] && sn=reclaim-verify
  if ! sz=$(dev_size "$dev"); then
    vset "$dev" "$ph" "full: size unknown, nothing read"
    record "$dev" "$sn" failed "cannot read the device size"
    return 1
  fi
  log "verify (full read-back, direct I/O): reading all $sz bytes of $dev"
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '%s[dry-run]%s dd if=%q bs=16M iflag=direct,skip_bytes,count_bytes,fullblock skip=0 count=%s status=progress | cmp -n %s - /dev/zero\n' \
      "$c_ylw" "$c_rst" "$dev" "$sz" "$sz" >&2
    vset "$dev" "$ph" "full (dry-run: nothing read)"
    record "$dev" "$sn" ok "would read all $sz bytes"
    return 0
  fi
  if zero_read "$dev" 0 "$sz" direct progress; then
    vset "$dev" "$ph" "full: $sz of $sz bytes read, all zero"
    [[ $ph == reclaim ]] || DEV_VOK[$dev]=full
    record "$dev" "$sn" ok "full read-back: $sz of $sz bytes read, all zero"
    good "VERIFIED${ph:+ ($ph)}: $dev reads back all zero, $sz of $sz bytes"
    return 0
  fi
  why=$(vwhy "$ph" "$(zr_reason 0)")
  vset "$dev" "$ph" "full: FAILED - $why"
  record "$dev" "$sn" failed "$why"
  err "VERIFY FAILED${ph:+ ($ph)} on $dev: $why"
  return 1
}

# First block, last (possibly partial) block, and N random full blocks.
# Offsets come from 64 random bits each, so disks over 4 TiB are covered.
verify_sample() {
  local dev="$1" n="$2" ph="${3:-}" bs=$SAMPLE_BS sz full rem nums v s off len bad=0 read=0 cnt=0 why sn=verify
  local arr=() samples=()
  [[ $ph == reclaim ]] && sn=reclaim-verify
  if ! sz=$(dev_size "$dev"); then
    vset "$dev" "$ph" "sampled: size unknown, nothing read"
    record "$dev" "$sn" failed "cannot read the device size"
    return 1
  fi
  full=$(( sz / bs )); rem=$(( sz % bs ))
  if (( sz <= bs )); then
    samples=("0:$sz")
  else
    samples=("0:$bs")
    if (( rem > 0 )); then samples+=("$(( full * bs )):$rem")
    else samples+=("$(( (full - 1) * bs )):$bs"); fi
  fi
  if (( full > 0 && n > 0 )); then
    if ! nums=$(od -An -v -N$(( 8 * n )) -tu8 /dev/urandom); then
      vset "$dev" "$ph" "sampled: no random source, nothing read"
      record "$dev" "$sn" failed "cannot read /dev/urandom"
      return 1
    fi
    read -r -d '' -a arr <<< "$nums" || true   # -d '' always ends at EOF with status 1
    if [[ ${#arr[@]} -ne $n ]]; then
      vset "$dev" "$ph" "sampled: short random read, nothing read"
      record "$dev" "$sn" failed "got ${#arr[@]} of $n random offsets"
      return 1
    fi
    for v in "${arr[@]}"; do
      [[ $v =~ ^[0-9]+$ ]] || { record "$dev" "$sn" failed "bad random value '$v'"; return 1; }
      samples+=("$(( ((v & 0x7fffffffffffffff) % full) * bs )):$bs")
    done
  fi
  log "verify (sampled, direct I/O): ${#samples[@]} blocks of $dev (first, last, $n random of 1 MiB)"
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '%s[dry-run]%s per block: dd if=%q bs=16M iflag=direct,skip_bytes,count_bytes,fullblock skip=OFF count=LEN | cmp -n LEN - /dev/zero\n' \
      "$c_ylw" "$c_rst" "$dev" >&2
    vset "$dev" "$ph" "sampled (dry-run: nothing read)"
    record "$dev" "$sn" ok "would read ${#samples[@]} blocks"
    return 0
  fi
  for s in "${samples[@]}"; do
    off=${s%%:*}; len=${s##*:}
    if zero_read "$dev" "$off" "$len" direct; then
      read=$(( read + len )); cnt=$(( cnt + 1 ))
    else
      why=$(zr_reason "$off")
      err "sample at offset $off ($len bytes) on $dev: $why"
      bad=$(( bad + 1 ))
    fi
  done
  if [[ $bad -eq 0 ]]; then
    vset "$dev" "$ph" "sampled: $cnt blocks, $read of $sz bytes read, all zero (proves only these blocks)"
    [[ $ph == reclaim ]] || DEV_VOK[$dev]=sampled
    record "$dev" "$sn" ok "sampled: $cnt blocks, $read of $sz bytes read, all zero"
    good "VERIFIED (sampled): $cnt blocks of $dev read back all zero; unsampled blocks are unproven"
    return 0
  fi
  why=$(vwhy "$ph" "$bad of ${#samples[@]} sampled blocks not verified zero")
  vset "$dev" "$ph" "sampled: FAILED - $why"
  record "$dev" "$sn" failed "$why"
  return 1
}

# sig_offsets DEV OFFSET_BASE: byte offsets (on the whole disk) of the
# signatures wipefs finds on DEV, which starts OFFSET_BASE bytes in.
sig_offsets() {
  local out o arr=()
  out=$(wipefs -i -p -O OFFSET -- "$1" 2>/dev/null) || return 1
  read -r -d '' -a arr <<< "$out" || true
  for o in "${arr[@]}"; do
    [[ $o =~ ^(0x[0-9a-fA-F]+|[0-9]+)$ ]] && printf '%s ' $(( $2 + o ))
  done
  return 0
}

# Before anything is erased: where wipefs sees signatures on the disk and its
# partitions, plus each partition's start. These are the places a still-live
# filesystem or partition table writes its metadata back to.
collect_sigs() {
  local dev="$1" p n start offs="" bad="" arr=()
  if [[ $DRY_RUN -eq 1 ]]; then
    record "$dev" signatures ok "would note where wipefs finds signatures on $dev and its partitions"
    return 0
  fi
  offs=$(sig_offsets "$dev" 0) || bad+=" ${dev##*/}"
  for p in "/sys/block/${dev##*/}/${dev##*/}"*; do
    [[ -r $p/partition && -r $p/start ]] || continue
    start=""; { read -r start < "$p/start"; } 2>/dev/null || true
    [[ $start =~ ^[0-9]+$ ]] || { bad+=" ${p##*/}"; continue; }
    start=$(( start * 512 ))
    offs+="$start "
    n=$(sig_offsets "/dev/${p##*/}" "$start") || { bad+=" ${p##*/}"; continue; }
    offs+=$n
  done
  DEV_SIGS[$dev]=$offs
  read -r -a arr <<< "$offs" || true
  n=${#arr[@]}
  if [[ -n $bad ]]; then
    record "$dev" signatures unsupported "$n offsets noted; wipefs could not scan:$bad (the re-check still reads block 0 and partition starts)"
  else
    record "$dev" signatures ok "$n offsets noted (signatures and partition starts)"
  fi
  return 0
}

# After the settle: re-read the SIG_BS block around each noted offset. A
# filesystem the kernel still holds (in-place) puts its superblock back there.
recheck_sigs() {
  local dev="$1" sz o off len why bad=0 cnt=0 arr=()
  local -A blk=()
  if [[ $DRY_RUN -eq 1 ]]; then
    record "$dev" recheck ok "would re-read the block around each noted signature offset"
    return 0
  fi
  if ! sz=$(dev_size "$dev"); then record "$dev" recheck failed "cannot read the device size"; return 1; fi
  blk[0]=1
  read -r -a arr <<< "${DEV_SIGS[$dev]:-}" || true
  for o in "${arr[@]}"; do
    (( o >= 0 && o < sz )) && blk[$(( o / SIG_BS * SIG_BS ))]=1
  done
  for off in "${!blk[@]}"; do
    len=$SIG_BS
    (( off + len > sz )) && len=$(( sz - off ))
    if zero_read "$dev" "$off" "$len" direct; then
      cnt=$(( cnt + 1 ))
    else
      why=$(zr_reason "$off")
      err "re-check at offset $off ($len bytes) on $dev: $why"
      bad=$(( bad + 1 ))
    fi
  done
  if [[ $bad -eq 0 ]]; then
    record "$dev" recheck ok "$cnt signature blocks still zero"
    return 0
  fi
  DEV_VERIFY[$dev]+="; RE-CHECK FAILED - $bad of ${#blk[@]} signature blocks written after the verify"
  record "$dev" recheck failed "$bad of ${#blk[@]} signature blocks no longer zero: written after the verify"
  return 1
}

# In place, with a filesystem directly on the whole disk (--in-place-whole-
# disk): nothing could stop the running kernel from writing its superblock
# back while zeroize went on past it. Just before the fence, read each
# signature block again and zero what is no longer zero; the fence follows at
# once and the verify reads the result. Written bytes here mean the kernel
# wrote to the disk during zeroize.
rezero_sigs() {
  local dev="$1" sz o off n=0 bad=0 arr=()
  local -A blk=()
  if [[ $DRY_RUN -eq 1 ]]; then
    record "$dev" rezero-sigs ok "would re-read the signature blocks and zero them again just before the fence"
    return 0
  fi
  if ! sz=$(dev_size "$dev"); then record "$dev" rezero-sigs failed "cannot read the device size"; return 1; fi
  blk[0]=1
  read -r -a arr <<< "${DEV_SIGS[$dev]:-}" || true
  for o in "${arr[@]}"; do
    (( o >= 0 && o < sz )) && blk[$(( o / SIG_BS * SIG_BS ))]=1
  done
  NORM_READ=0 NORM_WRITTEN=0 NORM_RUNS=0 NORM_SUBS=0 NORM_CHUNKS=0 NORM_RERRS=0 NORM_WERRS=0 NORM_NERR=0
  NORM_RERR1="" NORM_WERR1=""
  for off in "${!blk[@]}"; do
    norm_chunk "$dev" "$off" $(( off + SIG_BS > sz ? sz - off : SIG_BS )) || bad=1
    n=$(( n + 1 ))
  done
  if [[ $bad -ne 0 ]]; then
    record "$dev" rezero-sigs failed "${NORM_RERR1:-${NORM_WERR1:-error}} (re-reading $n signature blocks)"
    return 1
  fi
  if [[ $NORM_WRITTEN -gt 0 ]]; then
    record "$dev" rezero-sigs ok "$n signature blocks re-read just before the fence; $NORM_CHUNKS held data again (written by the running kernel during zeroize): $(commas "$NORM_WRITTEN") bytes zeroed again"
  else
    record "$dev" rezero-sigs ok "$n signature blocks re-read just before the fence: still zero"
  fi
  return 0
}

# ---- per-file proof ---------------------------------------------------------

# Always mapped, inventory or not. Globs are expanded at map time.
FILEMAP_BUILTIN=(/etc/shadow /etc/gshadow '/etc/ssh/ssh_host_*key' /etc/ssl/private /etc/letsencrypt
                 /etc/wireguard /etc/krb5.keytab /root/.ssh '/home/*/.ssh' /var/lib/tailscale)
FM_BATCH=256
FM_FILES=0 FM_EXTS=0 FM_SKIPPED=0 FM_EMPTY=0 FM_CAPPED=0 FM_OFFROOTS=0 FM_PRUNED=0 FM_SRC="" FM_NOTE=""
FMI_MP=() FMI_TGT=()       # fm_mounts: decoded mountpoints, on-target flags
declare -A FM_LOC=()        # path -> "DISK START_BYTES" for files on a target
declare -A FM_UNMAPPED=()   # reason -> number of files
declare -A FM_DEVCLS=()     # st_dev -> map:DISK:START | skip | unmapped:REASON

# target_path KNAME: the TARGETS entry for a whole target disk.
target_path() {
  local t
  for t in "${TARGETS[@]}"; do [[ ${t##*/} == "$1" ]] && { printf '%s' "$t"; return 0; }; done
  return 1
}

# fm_class DEV_T PATH: classify a filesystem's device. FIEMAP reports offsets
# relative to the filesystem's own block device, which is a plain disk offset
# only for a whole target disk or a partition of one.
fm_class() {
  local dt="$1" maj min mm sys k="" key val parent start disk
  maj=$(( ((dt >> 8) & 0xfff) | ((dt >> 32) & ~0xfff) ))
  min=$(( (dt & 0xff) | ((dt >> 12) & ~0xff) ))
  mm=$maj:$min
  if (( maj == 0 )); then
    if [[ -n ${TGT_MNT[$(mount_id_of "$2")]:-} ]]; then
      printf 'unmapped:anonymous device %s (btrfs or overlay); FIEMAP offsets are not disk offsets' "$mm"
    else printf skip; fi
    return 0
  fi
  [[ -n ${TGT_DEVS[$mm]:-} ]] || { printf skip; return 0; }
  sys=/sys/dev/block/$mm
  { while IFS='=' read -r key val; do [[ $key == DEVNAME ]] && k=$val; done < "$sys/uevent"; } 2>/dev/null || true
  [[ -n $k ]] || { printf 'unmapped:cannot name block device %s' "$mm"; return 0; }
  if disk=$(target_path "$k"); then printf 'map:%s:0' "$disk"; return 0; fi
  if [[ -r $sys/partition ]]; then
    parent=$(cd -P -- "$sys/.." 2>/dev/null && pwd -P) || parent=""
    parent=${parent##*/}
    start=""; { read -r start < "$sys/start"; } 2>/dev/null || true
    if disk=$(target_path "$parent") && [[ $start =~ ^[0-9]+$ ]]; then
      printf 'map:%s:%s' "$disk" $(( start * 512 )); return 0
    fi
    printf 'unmapped:partition %s: cannot find its start on a target disk' "$k"; return 0
  fi
  printf 'unmapped:on %s (dm/md/stacked device); not a plain partition offset' "$k"
}

fm_unmapped() { FM_UNMAPPED[$1]=$(( ${FM_UNMAPPED[$1]:-0} + 1 )); }

# filefrag_parse OUTFILE: read `filefrag -v` output on stdin, append one line
# per physical extent of a file in FM_LOC to OUTFILE: DISK OFFSET LENGTH PATH
# (tab-separated, byte units on the target disk). Extents with no fixed disk
# location are counted as unmapped. Sets FM_SEEN[path] for each file reported.
filefrag_parse() {
  local out="$1" line cur="" bs=0 disk base p0 p1 n flags
  while IFS= read -r line; do
    if [[ $line =~ ^File\ size\ of\ (.*)\ is\ [0-9]+\ \([0-9]+\ blocks?\ of\ ([0-9]+)\ bytes\)$ ]]; then
      cur=${BASH_REMATCH[1]} bs=${BASH_REMATCH[2]}
      if [[ -z ${FM_LOC[$cur]:-} ]]; then cur=""; continue; fi
      FM_SEEN[$cur]=1
      continue
    fi
    [[ -n $cur ]] || continue
    if [[ $line =~ ^\ *[0-9]+:\ *[0-9]+\.\.\ *[0-9]+:\ *([0-9]+)\.\.\ *([0-9]+):\ *([0-9]+):\ *([0-9]+:)?\ *([a-z_,]*)$ ]]; then
      p0=${BASH_REMATCH[1]} p1=${BASH_REMATCH[2]} n=${BASH_REMATCH[3]} flags=,${BASH_REMATCH[5]},
      case "$flags" in
        *,unknown_loc,*|*,delalloc,*|*,encoded,*|*,inline,*|*,tail_packed,*)
          fm_unmapped "extent with no fixed disk location (inline/unknown_loc/delalloc/encoded)"; continue ;;
      esac
      if (( p1 - p0 + 1 != n || bs <= 0 )); then fm_unmapped "unparsable filefrag extent"; continue; fi
      read -r disk base <<< "${FM_LOC[$cur]}"
      printf '%s\t%s\t%s\t%s\n' "$disk" $(( base + p0 * bs )) $(( n * bs )) "${cur//[$'\t\n']/ }" >> "$out"
      FM_EXTS=$(( FM_EXTS + 1 ))
    elif [[ $line == *": "*" extent"*" found" ]]; then
      [[ $line == *": 0 extents found" ]] && FM_EMPTY=$(( FM_EMPTY + 1 ))
      cur=""
    fi
  done
}

# fm_mounts: every mount in this namespace as FMI_MP[i] (decoded mountpoint)
# and FMI_TGT[i] (1 when it is backed by a target, from TGT_MNT).
fm_mounts() {
  local id mp
  FMI_MP=() FMI_TGT=()
  while read -r id _ _ _ mp _; do
    FMI_MP+=("$(mi_unescape "$mp")")
    if [[ -n ${TGT_MNT[$id]:-} ]]; then FMI_TGT+=(1); else FMI_TGT+=(0); fi
  done < /proc/self/mountinfo
  return 0
}

# fm_on_target PATH: true when the mount holding PATH (longest mountpoint
# prefix, the later mount on a tie) is on a target.
fm_on_target() {
  local i mp best=-1 blen=-1
  for (( i = 0; i < ${#FMI_MP[@]}; i++ )); do
    mp=${FMI_MP[i]}
    if [[ $mp == / || $1 == "$mp" || $1 == "$mp/"* ]] && (( ${#mp} >= blen )); then
      best=$i blen=${#mp}
    fi
  done
  (( best >= 0 )) && [[ ${FMI_TGT[best]} == 1 ]]
}

# under PATH DIR: PATH lies strictly below DIR.
under() { [[ $1 != "$2" && ( $2 == / || $1 == "$2/"* ) ]]; }

# map_files: before quiesce, while the target filesystems are mounted, map
# the built-in sensitive files and the inventory's paths to extents on the
# target disks ($SLAG_RAM/extents.tsv). Read back after each device's verify.
# Only target filesystems are walked: a listed path on another filesystem is
# skipped without a walk, and find prunes every mount under a listed path that
# leaves the targets (proc, sysfs, tmpfs, overlay, other disks, ...), and
# starts again at a target mount below such a mount.
map_files() {
  local inv="${SLAG_RAM:-}/inv-paths.txt" out="${SLAG_RAM:-}/extents.tsv" p g dt cls n=0 rest mp par r
  local roots=() real=() walk=() exits=() entries=() prune=() batch=()
  local -A seenroot=() rootseen=() seenmp=() inwalk=() inprune=()
  declare -gA FM_SEEN=()
  FM_FILES=0 FM_EXTS=0 FM_SKIPPED=0 FM_EMPTY=0 FM_CAPPED=0 FM_OFFROOTS=0 FM_PRUNED=0
  FM_LOC=() FM_UNMAPPED=() FM_DEVCLS=()
  FM_SRC="built-in list"
  [[ -s $inv ]] && FM_SRC="built-in list + inventory paths.txt"
  if [[ $DRY_RUN -eq 1 ]]; then
    record host file-map ok "would map files from the $FM_SRC to extents on the targets (filefrag -v), at most $FILEMAP_CAP on the targets"
    return 0
  fi
  if [[ -z ${SLAG_RAM:-} || ! -d $SLAG_RAM ]]; then record host file-map failed "no tmpfs scratch directory"; return 0; fi
  : > "$out"
  collect_target_tree
  collect_target_mounts
  # Rescue system: no file lives on a target as far as this host can see.
  if [[ ${#TGT_MNT[@]} -eq 0 ]]; then
    FM_SRC="" FM_NOTE="no filesystem on the targets is mounted (rescue): no file to map; the full verify covers every byte"
    record host file-map skipped "$FM_NOTE"
    return 0
  fi
  if ! have filefrag || ! have find; then
    record host file-map unsupported "filefrag or find not available; no per-file proof"; return 0
  fi
  for g in "${FILEMAP_BUILTIN[@]}"; do
    for p in $g; do   # unquoted: glob expansion
      [[ -e $p && -z ${rootseen[$p]:-} ]] && { rootseen[$p]=1; roots+=("$p"); }
    done
  done
  if [[ -s $inv ]]; then
    while IFS= read -r p; do
      case "$p" in /*) ;; *) continue ;; esac
      # `/` came from inventories that recorded daemons' working directory.
      case "$p" in /|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run/slag.*) continue ;; esac
      [[ -e $p && -z ${rootseen[$p]:-} ]] && { rootseen[$p]=1; roots+=("$p"); }
    done < "$inv"
  fi
  [[ ${#roots[@]} -gt 0 ]] || { record host file-map ok "no listed sensitive path exists"; return 0; }
  # Canonical paths, so the mount lookups and find's -path tests see what find prints.
  if have realpath; then
    mapfile -d '' -t real < <(realpath -e -z -- "${roots[@]}" 2>/dev/null)
  fi
  [[ ${#real[@]} -gt 0 ]] || real=("${roots[@]}")

  fm_mounts
  # Where a walk would leave the targets (exits) or come back onto them (entries).
  for mp in "${FMI_MP[@]}"; do
    [[ $mp != / && $mp != *$'\n'* && -z ${seenmp[$mp]:-} ]] || continue
    seenmp[$mp]=1
    par=${mp%/*}; par=${par:-/}
    if fm_on_target "$mp"; then
      fm_on_target "$par" || entries+=("$mp")
    else
      fm_on_target "$par" && exits+=("$mp")
    fi
  done
  for r in "${real[@]}"; do
    [[ -z ${inwalk[$r]:-} ]] || continue
    if fm_on_target "$r"; then inwalk[$r]=1; walk+=("$r"); else FM_OFFROOTS=$(( FM_OFFROOTS + 1 )); fi
    for mp in "${entries[@]}"; do
      under "$mp" "$r" && [[ -z ${inwalk[$mp]:-} ]] && { inwalk[$mp]=1; walk+=("$mp"); }
    done
  done
  for mp in "${exits[@]}"; do
    for r in "${walk[@]}"; do
      under "$mp" "$r" || continue
      [[ -n ${inprune[$mp]:-} ]] && break
      inprune[$mp]=1; FM_PRUNED=$(( FM_PRUNED + 1 ))
      p=${mp//\\/\\\\}; p=${p//\*/\\*}; p=${p//\?/\\?}; p=${p//\[/\\[}
      prune+=(${prune[0]+-o} -path "$p")
      break
    done
  done
  [[ ${#prune[@]} -gt 0 ]] && prune=('(' "${prune[@]}" ')' -prune -o)
  if [[ ${#walk[@]} -eq 0 ]]; then
    record host file-map ok "$(fm_summary)"
    return 0
  fi

  log "file-extent proof: mapping regular files under ${#walk[@]} sensitive paths on the targets ($FM_SRC; $FM_OFFROOTS listed paths on other filesystems, $FM_PRUNED mounts of other filesystems not walked)"
  while IFS= read -r -d '' rest; do
    dt=${rest%% *} p=${rest#* }
    [[ $dt =~ ^[0-9]+$ && -z ${seenroot[$p]:-} ]] || continue
    seenroot[$p]=1
    cls=${FM_DEVCLS[$dt]:-}
    [[ -n $cls ]] || { cls=$(fm_class "$dt" "$p"); FM_DEVCLS[$dt]=$cls; }
    # Only files on a target count toward the cap.
    if [[ $cls == skip ]]; then FM_SKIPPED=$(( FM_SKIPPED + 1 )); continue; fi
    if (( n >= FILEMAP_CAP )); then FM_CAPPED=1; break; fi
    n=$(( n + 1 ))
    case "$cls" in
      unmapped:*) fm_unmapped "${cls#unmapped:}" ;;
      map:*)
        if [[ $p == *$'\n'* ]]; then fm_unmapped "newline in the file name"; continue; fi
        cls=${cls#map:}
        FM_LOC[$p]="${cls%:*} ${cls##*:}"
        batch+=("$p") ;;
    esac
  done < <(find -H "${walk[@]}" "${prune[@]}" -type f -printf '%D %p\0' 2>/dev/null)
  for (( g = 0; g < ${#batch[@]}; g += FM_BATCH )); do
    filefrag_parse "$out" < <(filefrag -s -v -- "${batch[@]:g:FM_BATCH}" 2>/dev/null)
  done
  for p in "${batch[@]}"; do
    if [[ -n ${FM_SEEN[$p]:-} ]]; then FM_FILES=$(( FM_FILES + 1 )); else fm_unmapped "filefrag could not map it"; fi
  done
  record host file-map ok "$(fm_summary)"
  return 0
}

fm_summary() {
  [[ -n $FM_SRC ]] || { printf '%s' "${FM_NOTE:-not run}"; return 0; }
  local r s="$FM_FILES files on the targets mapped to $FM_EXTS extents ($FM_SRC; $FM_EMPTY empty; $FM_SKIPPED not on a target)"
  (( FM_OFFROOTS + FM_PRUNED == 0 )) \
    || s+="; not walked: $FM_OFFROOTS listed paths on other filesystems, $FM_PRUNED mounts of other filesystems under listed paths"
  for r in "${!FM_UNMAPPED[@]}"; do s+="; unmapped: ${FM_UNMAPPED[$r]} x $r"; done
  [[ $FM_CAPPED -eq 1 ]] && s+="; CAP of $FILEMAP_CAP files on the targets hit, the rest were not mapped"
  printf '%s' "$s"
}

# verify_extents DEV: re-read every mapped file extent on DEV, aligned out to
# 4 KiB, with direct I/O. All must be zero. Prints no data, only paths.
verify_extents() {
  local dev="$1" f="${SLAG_RAM:-}/extents.tsv" d off len path sz a e files=0 exts=0 bytes=0 bad=0 badnames="" why more=""
  local -A fs=()
  if [[ $DRY_RUN -eq 1 ]]; then record "$dev" file-extents ok "would re-read every mapped sensitive-file extent"; return 0; fi
  if [[ -z ${SLAG_RAM:-} || ! -r $f ]]; then
    DEV_EXT[$dev]="not run (no extent map)"; record "$dev" file-extents skipped "no extent map"; return 0
  fi
  if ! sz=$(dev_size "$dev"); then record "$dev" file-extents failed "cannot read the device size"; return 1; fi
  while IFS=$'\t' read -r d off len path; do
    [[ $d == "$dev" ]] || continue
    a=$(( off / 4096 * 4096 )); e=$(( (off + len + 4095) / 4096 * 4096 ))
    (( e > sz )) && e=$sz
    if (( a >= e )); then
      bad=$(( bad + 1 )); why="extent past the device end"
    elif zero_read "$dev" "$a" $(( e - a )) direct; then
      [[ -n ${fs[$path]:-} ]] || { fs[$path]=1; files=$(( files + 1 )); }
      exts=$(( exts + 1 )); bytes=$(( bytes + e - a )); continue
    else
      bad=$(( bad + 1 )); why=$(zr_reason "$a")
    fi
    err "file extent at offset $a on $dev ($path): $why"
    [[ -n ${fs[$path]:-} ]] || { fs[$path]=1; files=$(( files + 1 )); }
    (( bad <= 10 )) && badnames+="${badnames:+, }$path"
  done < "$f"
  if (( exts + bad == 0 )); then
    DEV_EXT[$dev]="no mapped sensitive-file extents on this device"
    record "$dev" file-extents skipped "no mapped sensitive-file extents on this device"
    return 0
  fi
  if (( bad == 0 )); then
    DEV_EXT[$dev]="$files files, $exts extents, $(commas "$bytes") bytes re-read, all zero"
    record "$dev" file-extents ok "${DEV_EXT[$dev]}"
    return 0
  fi
  (( bad > 10 )) && more=", ..."
  DEV_EXT[$dev]="FAILED - $bad of $(( exts + bad )) extents of $files files not zero: $badnames$more"
  record "$dev" file-extents failed "${DEV_EXT[$dev]#FAILED - }"
  return 1
}

# ---- hardware erase --------------------------------------------------------

# Parse the Security section of `hdparm -I`. Each flag line is "\t\tNAME" when
# set and "\tnot\tNAME" when clear.
ata_security() {
  local line in_sec=0
  ATA_SUP=0 ATA_EN=0 ATA_LOCKED=0 ATA_FROZEN=0 ATA_ENH=0 ATA_MIN=""
  while IFS= read -r line; do
    if [[ $line == Security:* ]]; then in_sec=1; continue; fi
    [[ $in_sec -eq 1 ]] || continue
    [[ $line == $'\t'* ]] || break
    line=${line%"${line##*[![:space:]]}"}
    case "$line" in
      $'\t\tsupported')                  ATA_SUP=1 ;;
      $'\t\tenabled')                    ATA_EN=1 ;;
      $'\t\tlocked')                     ATA_LOCKED=1 ;;
      $'\t\tfrozen')                     ATA_FROZEN=1 ;;
      $'\t\tsupported: enhanced erase')  ATA_ENH=1 ;;
    esac
    [[ $line =~ ([0-9]+)min\ for\ (ENHANCED\ )?SECURITY ]] && ATA_MIN=${BASH_REMATCH[1]}
  done <<< "$1"
  return 0
}

# Returns 0 only if an ATA security erase completed.
ata_erase() {
  local dev="$1" info rc=0 kind=--security-erase what=ata-erase
  if ! have hdparm; then record "$dev" ata-erase unsupported "hdparm not available"; return 1; fi
  if [[ $DRY_RUN -eq 1 ]]; then
    run hdparm -I "$dev"
    run hdparm --user-master u --security-set-pass slag "$dev"
    run hdparm --user-master u --security-erase-enhanced slag "$dev"
    record "$dev" ata-erase ok "checks security support and frozen state first"
    return 0
  fi
  if ! info=$(hdparm -I "$dev" 2>&1 </dev/null); then
    record "$dev" ata-erase unsupported "hdparm -I: $(lastline "$info")"
    return 1
  fi
  ata_security "$info"
  if [[ $ATA_SUP -eq 0 ]]; then
    record "$dev" ata-erase unsupported "no ATA security feature set"; return 1
  fi
  if [[ $ATA_FROZEN -eq 1 ]]; then
    record "$dev" ata-erase frozen "security frozen by BIOS/firmware; a suspend/resume or hot-replug usually unfreezes it"
    return 1
  fi
  if [[ $ATA_EN -eq 1 || $ATA_LOCKED -eq 1 ]]; then
    record "$dev" ata-erase failed "a drive password is already set (enabled=$ATA_EN locked=$ATA_LOCKED); not touching it"
    return 1
  fi
  step "$dev" ata-set-pass hdparm --user-master u --security-set-pass slag "$dev" || return 1
  if [[ $ATA_ENH -eq 1 ]]; then kind=--security-erase-enhanced; what=ata-erase-enhanced; fi
  log "$what on $dev: blocks until the drive is done (drive estimate: ${ATA_MIN:-unknown} min)"
  step "$dev" "$what" hdparm --user-master u "$kind" slag "$dev" || rc=$?
  if [[ $rc -ne 0 ]]; then
    # The password set above stays set when the erase fails; clear it or the
    # drive is left locked with password "slag".
    step "$dev" ata-security-disable hdparm --user-master u --security-disable slag "$dev" || true
  fi
  if info=$(hdparm -I "$dev" 2>&1 </dev/null); then
    ata_security "$info"
    if [[ $ATA_EN -eq 1 || $ATA_LOCKED -eq 1 ]]; then
      record "$dev" ata-security-state failed "drive still has ATA password 'slag' set (locked=$ATA_LOCKED); unlock: hdparm --user-master u --security-disable slag $dev"
    else
      record "$dev" ata-security-state ok "no drive password left set"
    fi
  else
    record "$dev" ata-security-state failed "hdparm -I after erase: $(lastline "$info")"
  fi
  return "$rc"
}

scsi_sanitize() {
  local dev="$1" mode out rc
  if ! have sg_sanitize; then record "$dev" scsi-sanitize unsupported "sg_sanitize (sg3-utils) not available"; return 0; fi
  for mode in block crypto; do
    if [[ $DRY_RUN -eq 1 ]]; then step "$dev" "scsi-sanitize-$mode" sg_sanitize --quick "--$mode" "$dev"; return 0; fi
    # Without --early, sg_sanitize polls until the sanitize has finished.
    rc=0; out=$(run sg_sanitize --quick "--$mode" "$dev" 2>&1 </dev/null) || rc=$?
    if [[ $rc -eq 0 ]]; then record "$dev" "scsi-sanitize-$mode" ok "completed"; return 0; fi
    case "$rc" in
      5|9) record "$dev" "scsi-sanitize-$mode" unsupported "rc=$rc: $(lastline "$out")" ;;
      *)   record "$dev" "scsi-sanitize-$mode" failed "rc=$rc: $(lastline "$out")" ;;
    esac
  done
  return 0
}

nvme_class() {
  case "$1" in
    *INVALID_OPCODE*|*"Invalid Command Opcode"*|*INVALID_FIELD*|*"Invalid Field"*|*"not supported"*|*"Not supported"*)
      printf unsupported ;;
    *) printf failed ;;
  esac
}

# Sets SAN_ST (SSTAT bits 2:0, -1 if unreadable) and SAN_PROG (SPROG, -1).
nvme_sanitize_status() {
  local dev="$1" out
  SAN_ST=-1 SAN_PROG=-1 SAN_RAW=""
  if out=$(nvme sanitize-log "$dev" -o json 2>&1 </dev/null); then
    [[ $out =~ \"sprog\"[[:space:]]*:[[:space:]]*([0-9]+) ]] && SAN_PROG=${BASH_REMATCH[1]}
    if [[ $out =~ \"sstat\"[[:space:]]*:[[:space:]]*([0-9]+) ]]; then
      SAN_ST=$(( BASH_REMATCH[1] & 7 ))
    elif [[ $out =~ \"status\"[[:space:]]*:[[:space:]]*\"([^\"]*)\" ]]; then
      # newer nvme-cli nests sstat as an object with a text status
      case "${BASH_REMATCH[1]}" in
        *[Ii]n\ [Pp]rogress*)            SAN_ST=2 ;;
        *[Ff]ail*)                       SAN_ST=3 ;;
        *[Dd]eallocat*)                  SAN_ST=4 ;;
        *[Cc]ompleted\ [Ss]uccessfully*) SAN_ST=1 ;;
        *[Nn]ever*)                      SAN_ST=0 ;;
      esac
    fi
  fi
  if [[ $SAN_ST -lt 0 ]] && out=$(nvme sanitize-log "$dev" 2>&1 </dev/null); then
    [[ $out =~ \(SPROG\)[[:space:]]*:[[:space:]]*([0-9]+) ]] && SAN_PROG=${BASH_REMATCH[1]}
    [[ $out =~ \(SSTAT\)[[:space:]]*:[[:space:]]*(0[xX][0-9a-fA-F]+|[0-9]+) ]] \
      && SAN_ST=$(( BASH_REMATCH[1] & 7 ))
  fi
  SAN_RAW=$(lastline "${out:-}")
  return 0
}

# nvme sanitize returns as soon as the drive accepts it; the erase runs in the
# background and blocks I/O to the namespace. Poll until it ends.
nvme_sanitize_wait() {
  local dev="$1" start=$SECONDS unknown_since=$SECONDS
  log "waiting for the NVMe sanitize on $dev (timeout ${HW_TIMEOUT}s, polling every 5s)"
  while :; do
    nvme_sanitize_status "$dev"
    case "$SAN_ST" in
      1|4) record "$dev" nvme-sanitize-wait ok "completed after $(( SECONDS - start ))s (SSTAT status $SAN_ST)"
           return 0 ;;
      3)   record "$dev" nvme-sanitize-wait failed "drive reports the sanitize FAILED"
           return 1 ;;
      2)   unknown_since=$SECONDS
           if [[ $SAN_PROG -ge 0 ]]; then log "  sanitize $dev: $(( SAN_PROG * 100 / 65536 ))% after $(( SECONDS - start ))s"
           else log "  sanitize $dev: in progress after $(( SECONDS - start ))s"; fi ;;
      *)   if (( SECONDS - unknown_since > 60 )); then
             record "$dev" nvme-sanitize-wait failed "no usable sanitize status for 60s (SSTAT=$SAN_ST): $SAN_RAW"
             return 1
           fi ;;
    esac
    if (( SECONDS - start >= HW_TIMEOUT )); then
      record "$dev" nvme-sanitize-wait failed "timed out after ${HW_TIMEOUT}s, still running; writes fail until it ends"
      return 1
    fi
    sleep 5
  done
}

nvme_erase() {
  local dev="$1" act label out rc ses force=()
  NVME_HW=""
  if ! have nvme; then record "$dev" nvme-erase unsupported "nvme-cli not available"; return 0; fi
  if [[ $DRY_RUN -eq 1 ]]; then
    run nvme sanitize "$dev" -a 4
    run nvme sanitize-log "$dev" -o json
    record "$dev" nvme-sanitize-crypto ok "then polls sanitize-log; falls back to block erase, then format -s 2 / -s 1"
    NVME_HW=sanitized
    return 0
  fi
  for act in 4 2; do
    if [[ $act == 4 ]]; then label="nvme-sanitize-crypto"; else label="nvme-sanitize-block"; fi
    rc=0; out=$(run nvme sanitize "$dev" -a "$act" 2>&1 </dev/null) || rc=$?
    if [[ $rc -eq 0 ]]; then
      record "$dev" "$label" ok "accepted; erasing in the background"
      if nvme_sanitize_wait "$dev"; then NVME_HW=sanitized; return 0; fi
      # timed out mid-run: nothing else can start
      [[ $SAN_ST -eq 2 ]] && { NVME_HW=running; return 0; }
    else
      record "$dev" "$label" "$(nvme_class "$out")" "rc=$rc: $(lastline "$out")"
    fi
  done
  # Format is synchronous: it returns when the erase is done.
  out=$(nvme format --help 2>&1) || true
  [[ $out == *--force* ]] && force=(--force)
  for ses in 2 1; do
    if [[ $ses == 2 ]]; then label="nvme-format-crypto"; else label="nvme-format-user-data"; fi
    rc=0; out=$(run nvme format "$dev" -s "$ses" "${force[@]}" 2>&1 </dev/null) || rc=$?
    if [[ $rc -eq 0 ]]; then record "$dev" "$label" ok "completed"; return 0; fi
    record "$dev" "$label" "$(nvme_class "$out")" "rc=$rc: $(lastline "$out")"
  done
  return 0
}

hw_erase() {
  local dev="$1" name="${1##*/}"
  NVME_HW=""
  if [[ $HW_ERASE -eq 0 ]]; then record "$dev" hw-erase skipped "--no-hw-erase"; return 0; fi
  case "$name" in
    nvme*n*) nvme_erase "$dev" ;;
    sd*)     ata_erase "$dev" || scsi_sanitize "$dev" ;;
    *)       record "$dev" hw-erase unsupported "no hardware erase path for $name (virtual device?)" ;;
  esac
  return 0
}

# Hardware-erase every target before any target is overwritten or verified:
# an NVMe sanitize (or a crypto format with FNA set) erases every namespace
# behind the controller, so one run later would undo an earlier namespace's
# verified zeros. A sanitize goes out once per controller; resolve_targets
# made sure all its namespaces are targets.
hw_erase_all() {
  local d c
  local -A via=() state=()
  for d in "${TARGETS[@]}"; do
    c=""
    [[ ${d##*/} =~ ^(nvme[0-9]+)n[0-9]+$ ]] && c=${BASH_REMATCH[1]}
    if [[ $HW_ERASE -eq 1 && -n $c && -n ${via[$c]:-} ]]; then
      if [[ ${state[$c]} == sanitized ]]; then
        record "$d" nvme-sanitize ok "covered by the sanitize sent via ${via[$c]} (every namespace on $c)"
      else
        record "$d" nvme-sanitize skipped "the sanitize sent via ${via[$c]} is still running; writes fail until it ends"
      fi
      continue
    fi
    log "=== hardware erase: $d (${DEV_MODEL[$d]}, serial ${DEV_SERIAL[$d]}, ${DEV_SIZE[$d]} bytes, rotational=$(is_rotational "$d" && echo yes || echo no)) ==="
    hw_erase "$d"
    if [[ -n $c && -n $NVME_HW ]]; then via[$c]=$d; state[$c]=$NVME_HW; fi
  done
  return 0
}

# An ATA Host Protected Area hides sectors past the size the kernel reports,
# so neither the overwrite nor the verify reaches them.
hpa_check() {
  local dev="$1" out cur nat
  [[ ${dev##*/} == sd* ]] || return 0
  if ! have hdparm; then record "$dev" hpa-check unsupported "hdparm not available; an HPA would go unnoticed"; return 0; fi
  if [[ $DRY_RUN -eq 1 ]]; then
    run hdparm -N "$dev"
    record "$dev" hpa-check ok "fails the device if an HPA hides sectors"
    return 0
  fi
  out=$(hdparm -N "$dev" 2>&1 </dev/null) || true
  if ! [[ $out =~ max\ sectors[[:space:]]*=[[:space:]]*([0-9]+)/([0-9]+) ]] || [[ ${BASH_REMATCH[1]} == 0 ]]; then
    record "$dev" hpa-check unsupported "hdparm -N: $(lastline "$out")"
    return 0
  fi
  cur=${BASH_REMATCH[1]} nat=${BASH_REMATCH[2]}
  if (( cur < nat )); then
    record "$dev" hpa-check failed "an HPA hides $(( nat - cur )) sectors past the visible end ($cur of $nat), not zeroized or verified; remove it (hdparm -N p$nat $dev) and wipe again"
    return 1
  fi
  record "$dev" hpa-check ok "no HPA (max sectors $cur/$nat)"
  return 0
}

# ---- zeroize ---------------------------------------------------

NORM_CHUNK=$((16 * 1024 * 1024))   # read unit
NORM_SUB=$((64 * 1024))            # write unit: qcow2's default cluster size
NORM_IFLAG=direct                  # dd iflag/oflag; the self-test clears them for its tmpfs file
NORM_OFLAG=direct
NORM_PROGRESS_SECS=15
NORM_MAX_FAILS=64                  # consecutive failed chunks before giving up (device gone)

# commas N: 3221225472 -> 3,221,225,472 (LC_ALL=C has no digit grouping).
commas() {
  local n="$1" out=""
  while (( ${#n} > 3 )); do out=",${n: -3}$out"; n=${n:0:${#n}-3}; done
  printf '%s' "$n$out"
}

# dd_bytes FILE: the byte count from the summary dd wrote to FILE, or -1.
dd_bytes() {
  local line n=-1
  { while IFS= read -r line; do
      [[ $line =~ ^([0-9]+)\ bytes ]] && n=${BASH_REMATCH[1]}
    done < "$1"; } 2>/dev/null || true
  printf '%s' "$n"
}

# norm_cmp FILE POS LEN: compare LEN bytes of FILE from POS with zeros.
# NC_RC 0: all zero; 1: nonzero data, first nonzero byte at POS+NC_AT;
# 2: error or EOF (the file is a full chunk, so EOF means a short read).
norm_cmp() {
  local msg="" rc=0
  cmp -n "$3" "$1" /dev/zero "$2" 0 > "$NORM_CMPF" 2>&1 || rc=$?
  { IFS= read -r msg < "$NORM_CMPF"; } 2>/dev/null || true
  NC_AT=-1
  if [[ $rc -eq 0 ]]; then NC_RC=0
  elif [[ $rc -eq 1 && $msg =~ differ:\ (byte|char)\ ([0-9]+) ]]; then NC_RC=1; NC_AT=$(( BASH_REMATCH[2] - 1 ))
  else NC_RC=2; NORM_ERR="cmp exit $rc: ${msg:0:160}"
  fi
}

# norm_write DEV OFF LEN: write LEN zero bytes at byte OFF, in place.
norm_write() {
  local rc=0 n why
  dd if=/dev/zero of="$1" bs=1M iflag=count_bytes,fullblock count="$3" \
    oflag="${NORM_OFLAG:+$NORM_OFLAG,}seek_bytes" seek="$2" conv=notrunc,fsync \
    </dev/null 2> "$NORM_ERRF" || rc=$?
  n=$(dd_bytes "$NORM_ERRF")
  if [[ $rc -ne 0 || $n != "$3" ]]; then
    why="write error at byte offset $2 (dd exit $rc, $n of $3 bytes written)"
    NORM_WERRS=$(( NORM_WERRS + 1 ))
    (( n > 0 )) && NORM_WRITTEN=$(( NORM_WRITTEN + n ))
    [[ -n $NORM_WERR1 ]] || NORM_WERR1=$why
    norm_fail "$why"
    return 1
  fi
  NORM_WRITTEN=$(( NORM_WRITTEN + $3 )); NORM_RUNS=$(( NORM_RUNS + 1 ))
  NORM_SUBS=$(( NORM_SUBS + ($3 + NORM_SUB - 1) / NORM_SUB ))
  return 0
}

# norm_fail MSG: one error of the zeroize pass; the first few are logged.
norm_fail() {
  NORM_NERR=$(( NORM_NERR + 1 ))
  (( NORM_NERR <= 10 )) && err "zeroize: $1"
  (( NORM_NERR == 10 )) && err "zeroize: further errors are counted, not logged"
  return 0
}

# norm_unreadable DEV OFF LEN WHY: a chunk that could not be read (or compared)
# holds unknown data, so the whole chunk is written with zeros.
norm_unreadable() {
  NORM_RERRS=$(( NORM_RERRS + 1 ))
  [[ -n $NORM_RERR1 ]] || NORM_RERR1=$4
  norm_fail "$4; writing zeros over the whole chunk"
  norm_write "$1" "$2" "$3" || true
  return 0
}

# norm_chunk DEV OFF LEN: read one chunk into tmpfs; if it is not all zero,
# zero each run of 64 KiB sub-blocks that holds nonzero data. One dd and one
# cmp for a zero chunk; a dense chunk costs one cmp per sub-block. Returns 1
# when any read or write in the chunk failed (recorded; the caller goes on).
norm_chunk() {
  local dev="$1" off="$2" len="$3" rc=0 n pos run slen bad=0
  dd if="$dev" of="$NORM_CHUNKF" bs=16M iflag="${NORM_IFLAG:+$NORM_IFLAG,}skip_bytes,count_bytes,fullblock" \
    skip="$off" count="$len" </dev/null 2> "$NORM_ERRF" || rc=$?
  n=$(dd_bytes "$NORM_ERRF")
  if [[ $rc -ne 0 || $n != "$len" ]]; then
    norm_unreadable "$dev" "$off" "$len" "read error at byte offset $off (dd exit $rc, $n of $len bytes read)"
    return 1
  fi
  norm_cmp "$NORM_CHUNKF" 0 "$len"
  case "$NC_RC" in
    0) NORM_READ=$(( NORM_READ + len )); return 0 ;;
    2) norm_unreadable "$dev" "$off" "$len" "chunk at byte offset $off: $NORM_ERR"; return 1 ;;
  esac
  NORM_CHUNKS=$(( NORM_CHUNKS + 1 ))
  pos=$(( NC_AT / NORM_SUB * NORM_SUB ))
  while (( pos < len )); do
    # pos starts a sub-block known to hold nonzero data; extend the run.
    run=$pos; pos=$(( pos + NORM_SUB ))
    while (( pos < len )); do
      slen=$(( len - pos < NORM_SUB ? len - pos : NORM_SUB ))
      norm_cmp "$NORM_CHUNKF" "$pos" "$slen"
      case "$NC_RC" in
        0) break ;;
        1) pos=$(( pos + NORM_SUB )) ;;
        *) # Compare failed: the rest of the chunk is unknown, so zero all of it.
           norm_unreadable "$dev" $(( off + run )) $(( len - run )) "chunk at byte offset $off: $NORM_ERR"
           return 1 ;;
      esac
    done
    (( pos > len )) && pos=$len
    norm_write "$dev" $(( off + run )) $(( pos - run )) || bad=1
    # pos is a zero sub-block (or the end): find the next nonzero byte after it.
    pos=$(( pos + NORM_SUB ))
    (( pos < len )) || break
    norm_cmp "$NORM_CHUNKF" "$pos" $(( len - pos ))
    case "$NC_RC" in
      0) break ;;
      1) pos=$(( (pos + NC_AT) / NORM_SUB * NORM_SUB )) ;;
      *) norm_unreadable "$dev" $(( off + pos )) $(( len - pos )) "chunk at byte offset $off: $NORM_ERR"
         return 1 ;;
    esac
  done
  NORM_READ=$(( NORM_READ + len ))
  return "$bad"
}

# zeroize_device DEV: the conditional zero pass. Every block is read with
# direct I/O; zeros are written only where a block is not already zero. A
# block holding data is already allocated, so the zeros overwrite it in place;
# a block that reads zero (unallocated in a thin layer, or zero already) is not
# written and so not allocated. Nothing is discarded here: a discard first
# would free the old blocks on thin storage instead of overwriting them.
# A read or write error does not stop the pass: the error is recorded, an
# unreadable chunk is zeroed whole, and the pass goes on with the next chunk;
# the step then FAILS. NORM_MAX_FAILS failed chunks in a row end it early.
# SIZE is for the self-test's regular file; a device's size comes from blockdev.
zeroize_device() {
  local dev="$1" sz="${2:-}" off len last pct fails=0 why stop=""
  NORM_READ=0 NORM_WRITTEN=0 NORM_CHUNKS=0 NORM_RUNS=0 NORM_SUBS=0 NORM_ERR=""
  NORM_RERRS=0 NORM_WERRS=0 NORM_NERR=0 NORM_RERR1="" NORM_WERR1=""
  if [[ -z $sz ]] && ! sz=$(dev_size "$dev"); then record "$dev" zeroize failed "cannot read the device size"; return 1; fi
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '%s[dry-run]%s per 16 MiB chunk: dd if=%q of=TMPFS/norm.chunk bs=16M iflag=direct,skip_bytes,count_bytes,fullblock skip=OFF count=LEN; cmp -n LEN TMPFS/norm.chunk /dev/zero\n' \
      "$c_ylw" "$c_rst" "$dev" >&2
    printf '%s[dry-run]%s per run of nonzero 64 KiB blocks: dd if=/dev/zero of=%q bs=1M iflag=count_bytes,fullblock count=LEN oflag=direct,seek_bytes seek=OFF conv=notrunc,fsync\n' \
      "$c_ylw" "$c_rst" "$dev" >&2
    DEV_NORM[$dev]="dry-run"
    record "$dev" zeroize ok "would read all $sz bytes and write zeros only to 64 KiB blocks not already zero"
    return 0
  fi
  if [[ -z ${SLAG_RAM:-} || ! -d $SLAG_RAM ]]; then
    record "$dev" zeroize failed "no tmpfs scratch directory (SLAG_RAM)"; return 1
  fi
  NORM_CHUNKF=$SLAG_RAM/norm.chunk NORM_ERRF=$SLAG_RAM/norm.err NORM_CMPF=$SLAG_RAM/norm.cmp
  log "zeroize $dev: reading all $sz bytes in 16 MiB chunks (direct I/O), writing zeros only to 64 KiB blocks that are not already zero"
  last=$SECONDS
  for (( off = 0; off < sz; off += NORM_CHUNK )); do
    len=$(( sz - off < NORM_CHUNK ? sz - off : NORM_CHUNK ))
    if norm_chunk "$dev" "$off" "$len"; then
      fails=0
    else
      fails=$(( fails + 1 ))
      if (( fails >= NORM_MAX_FAILS )); then
        off=$(( off + len ))
        (( off < sz )) && stop="; stopped after $fails failed chunks in a row (device gone?): the last $(commas $(( sz - off ))) bytes from byte offset $off were neither read nor written"
        break
      fi
    fi
    if (( SECONDS - last >= NORM_PROGRESS_SECS )); then
      last=$SECONDS
      pct=$(( (off + len) * 100 / sz ))
      log "  zeroize $dev: $(( (off + len) / 1073741824 )).$(( (off + len) * 10 / 1073741824 % 10 )) of $(( sz / 1073741824 )).$(( sz * 10 / 1073741824 % 10 )) GiB read ($pct%), $(commas "$NORM_WRITTEN") bytes written"
    fi
  done
  : > "$NORM_CHUNKF"
  if (( NORM_RERRS + NORM_WERRS > 0 )) || [[ -n $stop ]]; then
    why="$NORM_RERRS chunk(s) with read errors${NORM_RERR1:+ (first: $NORM_RERR1; each zeroed whole)}"
    why+="; $NORM_WERRS write error(s)${NORM_WERR1:+ (first: $NORM_WERR1)}$stop"
    DEV_NORM[$dev]="FAILED $NORM_READ $NORM_WRITTEN $why"
    record "$dev" zeroize failed "$why; $(commas "$NORM_READ") of $(commas "$sz") bytes read without error, $(commas "$NORM_WRITTEN") bytes written"
    err "zeroize FAILED on $dev: $why"
    return 1
  fi
  DEV_NORM[$dev]="ok $NORM_READ $NORM_WRITTEN"
  record "$dev" zeroize ok "conditional zero pass: $(commas "$NORM_READ") of $(commas "$sz") bytes read; $(commas "$NORM_WRITTEN") bytes written where a block was not already zero ($NORM_SUBS blocks of 64 KiB in $NORM_RUNS writes, in $NORM_CHUNKS of $(( (sz + NORM_CHUNK - 1) / NORM_CHUNK )) 16 MiB chunks)"
  return 0
}

# ---- overwrite patterns (--pattern) ----------------------------------------

# After zeroize has been verified, --pattern NAME writes one of the legacy
# multi-pass overwrite methods over every byte of each target and leaves its
# last pass on the disk. The verdict then rests on two proofs: the zero
# verify (every byte read back zero, as in a plain run) and the pattern's last
# pass read back in full, whose SHA-256 must equal the SHA-256 of the bytes
# written. Current standards (NIST SP 800-88r2, IEEE 2883) need none of this:
# one verified pass is Clear. These exist for policies that still name one.
# Each pass is a byte pattern in hex (1 or 3 bytes, repeated), R (random
# from /dev/urandom), or INV: the bitwise complement of the previous pass,
# read back from the disk block by block just ahead of being overwritten. Sequences as conventionally implemented; see
# docs/PATTERNS.md for the sources and where they are uncertain.
#
# name|passes|standard
PATTERN_DEFS=(
  'dod3|00 FF R|US DoD 5220.22-M (E): a character, its complement, random'
  'dod7|00 FF R R 00 FF R|US DoD 5220.22-M (ECE): the 3-pass, one random pass, the 3-pass again'
  'gutmann|R R R R 55 AA 924924 492492 249249 00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF 924924 492492 249249 6DB6DB B6DB6D DB6DB6 R R R R|Peter Gutmann (1996), 35 passes'
  'schneier|FF 00 R R R R R|Bruce Schneier, Applied Cryptography: ones, zeros, five random'
  'vsitr|00 FF 00 FF 00 FF AA|German BSI VSITR'
  'ops2|00 FF 00 FF 00 FF R|RCMP TSSIT OPS-II (Canada, retired)'
  'itsg06|00 FF R|CSEC ITSG-06 (Canada): a value, its complement, random'
  'hmg-baseline|00|UK HMG IS5 Baseline: one zero pass'
  'hmg-enhanced|00 FF R|UK HMG IS5 Enhanced: zeros, ones, random'
  'afssi5020|00 FF R|US Air Force AFSSI-5020'
  'ar380-19|R 00 FF|US Army AR 380-19: random, a character, its complement'
  'navso|00 FF R|US Navy NAVSO P-5239-26: a character, its complement, random'
  'gost|00 R|Russia GOST R 50739-95: zeros, random'
  'pfitzner|R R R R R R R R R R R R R R R R R R R R R R R R R R R R R R R R R|Roy Pfitzner: 33 random passes'
  'random|R|one random pass'
  'harmon|R INV|Harmon: zeroize (verified), random, then the complement of that random pass'
)
PAT_PASSES=() PAT_STD=""
# tr maps for INV: every byte value, and its complement (255 - b), as octal escapes.
PAT_SET=$(printf '\\%03o' {0..255})
PAT_INV=$(printf '\\%03o' {255..0})
PAT_BUF_MIB=3               # the repeated buffer: 3 MiB holds a whole number of 1- and 3-byte patterns
declare -A DEV_PATTERN=()   # device -> receipt sentence
declare -A DEV_PATSUM=()    # device -> SHA-256 of the last pass as read back

# pattern_lookup NAME: fills PAT_PASSES and PAT_STD, or returns 1.
pattern_lookup() {
  local e n p s
  for e in "${PATTERN_DEFS[@]}"; do
    IFS='|' read -r n p s <<< "$e"
    [[ $n == "$1" ]] || continue
    read -r -a PAT_PASSES <<< "$p"
    PAT_STD=$s
    return 0
  done
  return 1
}

# Before the confirmation: the name, and no mounted filesystem on a target.
# In place, the disks are set read-only before the zero verify, so nothing can
# be written after it; --pattern needs a rescue system (or the slag appliance).
pattern_check() {
  pattern_lookup "$PATTERN" || die "--pattern $PATTERN: unknown name (slag.sh patterns lists them)"
  collect_target_tree
  collect_target_mounts
  if [[ ${#TGT_MNT[@]} -gt 0 ]]; then
    err "--pattern needs a rescue system: filesystems on the targets are mounted (${TGT_MNT[*]})."
    err "In place, the disks are set read-only before the zero verify, so no pattern can follow it."
    die "refused; boot the provider's rescue system or the slag appliance"
  fi
  warn "--pattern $PATTERN ($PAT_STD): after the zero verify, ${#PAT_PASSES[@]} more full write pass(es)"
  warn "(${PAT_PASSES[*]}) and one more full read per target. The last pass stays on the disk: the"
  warn "disks will NOT read back zero afterwards. Every block gets written, so thin storage (qcow2,"
  warn "thin LVM, Ceph) becomes fully allocated."
  return 0
}

phase_patterns() {
  local e n p s c
  printf '%-13s %6s  %s\n' NAME PASSES STANDARD
  for e in "${PATTERN_DEFS[@]}"; do
    IFS='|' read -r n p s <<< "$e"
    read -r -a c <<< "$p"
    printf '%-13s %6s  %s\n' "$n" "${#c[@]}" "$s"
    printf '%-13s %6s  %s\n' "" "" "passes: $p"
  done
  echo
  echo "Each pass: a byte pattern in hex, repeated, R (random, /dev/urandom), or INV (the"
  echo "bitwise complement of the previous pass, read back from the disk as it is rewritten)."
  echo "slag.sh wipe --pattern NAME runs it after the zero pass has been verified."
}

# pat_buf TOKEN: $SLAG_RAM/pat.buf holds PAT_BUF_MIB MiB of TOKEN repeated.
pat_buf() {
  local t="$1" esc="" i f="$SLAG_RAM/pat.buf" g="$SLAG_RAM/pat.tmp"
  for (( i = 0; i < ${#t}; i += 2 )); do esc+="\\x${t:i:2}"; done
  (( ${#t} == 2 )) && esc="$esc$esc$esc"
  # shellcheck disable=SC2059
  printf "$esc" > "$f" || return 1
  for (( i = 0; i < 20; i++ )); do
    cat "$f" "$f" > "$g" && cat "$g" > "$f" || return 1
  done
  : > "$g"
  [[ $(wc -c < "$f") == $(( PAT_BUF_MIB * 1048576 )) ]]
}

# pat_stream TOKEN [DEV SIZE]: TOKEN's bytes on stdout until the reader stops.
# INV reads DEV itself: the writer is fed only what the reader has already
# read, so every block is inverted from its previous contents, never from
# bytes this pass already wrote.
pat_stream() {
  case $1 in
    R) cat /dev/urandom ;;
    INV) dd if="$2" bs=16M iflag="${NORM_IFLAG:+$NORM_IFLAG,}count_bytes,fullblock" count="$3" 2>/dev/null \
           | LC_ALL=C tr "$PAT_SET" "$PAT_INV" ;;
    *) while cat "$SLAG_RAM/pat.buf"; do :; done ;;
  esac
}

# pat_write DEV SIZE TOKEN [SUMFILE]: write SIZE bytes of TOKEN over DEV from
# byte 0. With SUMFILE, the SHA-256 of exactly the bytes handed to dd goes
# there. Sets PAT_ERR on failure.
pat_write() {
  local dev="$1" sz="$2" tok="$3" sumf="${4:-}" st=() fd=-1 hp="" n errf="$SLAG_RAM/pat.err" tgt=/dev/null
  local of=("conv=notrunc,fsync")
  [[ -z $NORM_OFLAG ]] || of+=("oflag=$NORM_OFLAG")
  PAT_ERR=""
  if [[ $tok != R && $tok != INV ]]; then pat_buf "$tok" || { PAT_ERR="cannot build the $tok buffer in tmpfs"; return 1; }; fi
  if [[ -n $sumf ]]; then
    exec {fd}> >(sha256sum > "$sumf")
    hp=$!
    tgt=/proc/self/fd/$fd
  fi
  { pat_stream "$tok" "$dev" "$sz" | head -c "$sz" | tee "$tgt" \
      | dd of="$dev" bs=16M iflag=fullblock "${of[@]}" 2> "$errf"
    st=("${PIPESTATUS[@]}"); } || :
  if [[ -n $sumf ]]; then exec {fd}>&-; wait "$hp" 2>/dev/null || true; fi
  n=$(dd_bytes "$errf")
  if [[ ${st[1]:-1} != 0 || ${st[2]:-1} != 0 || ${st[3]:-1} != 0 || $n != "$sz" ]]; then
    PAT_ERR="write failed (head ${st[1]:-?}, tee ${st[2]:-?}, dd ${st[3]:-?}; $n of $sz bytes written): $(lastline "$(< "$errf")")"
    return 1
  fi
  return 0
}

# pat_readsum DEV SIZE: SHA-256 of exactly SIZE bytes read from DEV (direct I/O).
pat_readsum() {
  local errf="$SLAG_RAM/pat.rerr" out st=() n
  { out=$(dd if="$1" bs=16M iflag="${NORM_IFLAG:+$NORM_IFLAG,}count_bytes,fullblock" count="$2" 2> "$errf" | sha256sum)
    st=("${PIPESTATUS[@]}"); } || :
  n=$(dd_bytes "$errf")
  if [[ ${st[0]:-1} != 0 || $n != "$2" ]]; then
    PAT_ERR="read-back failed (dd exit ${st[0]:-?}, $n of $2 bytes read)"
    return 1
  fi
  printf '%s' "${out%% *}"
}

# pattern_device DEV [SIZE]: every pass of PATTERN over the whole device, then
# the last pass read back. Runs only after the zero verify passed.
pattern_device() {
  local dev="$1" sz="${2:-}" i n=${#PAT_PASSES[@]} tok want got sumf="$SLAG_RAM/pat.sum" t0 s
  if [[ -z $sz ]] && ! sz=$(dev_size "$dev"); then
    DEV_PATTERN[$dev]="FAILED: cannot read the device size"
    record "$dev" pattern failed "cannot read the device size"; return 1
  fi
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '%s[dry-run]%s %s (%s passes: %s) over all %s bytes of %q, then dd if=%q iflag=direct | sha256sum against the last pass as written\n' \
      "$c_ylw" "$c_rst" "$PATTERN" "$n" "${PAT_PASSES[*]}" "$sz" "$dev" "$dev" >&2
    DEV_PATTERN[$dev]="$PATTERN (dry-run: nothing written)"
    record "$dev" pattern ok "would write $n passes and read the last one back"
    return 0
  fi
  log "=== pattern $PATTERN on $dev: $n passes over $sz bytes (${PAT_PASSES[*]}), then the last pass read back ==="
  for (( i = 0; i < n; i++ )); do
    tok=${PAT_PASSES[i]}
    t0=$SECONDS
    log "  pattern $PATTERN on $dev: pass $(( i + 1 )) of $n ($tok)"
    s=""; (( i == n - 1 )) && s=$sumf
    if ! pat_write "$dev" "$sz" "$tok" "$s"; then
      DEV_PATTERN[$dev]="FAILED in pass $(( i + 1 )) of $n ($tok): $PAT_ERR"
      record "$dev" pattern failed "pass $(( i + 1 )) of $n ($tok): $PAT_ERR"
      err "pattern FAILED on $dev: pass $(( i + 1 )) of $n ($tok): $PAT_ERR"
      return 1
    fi
    record "$dev" "pattern-pass-$(( i + 1 ))" ok "$tok over all $sz bytes in $(( SECONDS - t0 ))s"
  done
  { read -r want _ < "$sumf"; } 2>/dev/null || want=""
  : > "$SLAG_RAM/pat.buf" 2>/dev/null || true
  log "  pattern $PATTERN on $dev: reading back all $sz bytes of the last pass (direct I/O)"
  if ! got=$(pat_readsum "$dev" "$sz"); then
    DEV_PATTERN[$dev]="FAILED: $PAT_ERR"
    record "$dev" pattern-verify failed "$PAT_ERR"
    err "pattern verify FAILED on $dev: $PAT_ERR"
    return 1
  fi
  if [[ ! $want =~ ^[0-9a-f]{64}$ || $got != "$want" ]]; then
    DEV_PATTERN[$dev]="FAILED: the last pass reads back as sha256 $got, but ${want:-nothing} was written"
    record "$dev" pattern-verify failed "read back sha256 $got; written ${want:-?}"
    err "pattern verify FAILED on $dev: read back sha256 $got, written ${want:-?}"
    return 1
  fi
  DEV_PATSUM[$dev]=$got
  DEV_PATTERN[$dev]="$PATTERN, $n passes (${PAT_PASSES[*]}) over all $sz bytes after the zero verify; the last pass (${PAT_PASSES[n-1]}) read back in full, sha256 $got, matches what was written"
  record "$dev" pattern-verify ok "last pass read back: $sz bytes, sha256 $got (matches)"
  good "PATTERN VERIFIED: $dev holds the last pass of $PATTERN, $sz bytes, sha256 $got"
  return 0
}

# ---- strike (secrets first) ------------------------------------------------

# Before the hardware erase and zeroize, the strike zeroes key material
# first, so a run cut short (session lost, instance killed, power) has already
# destroyed the secrets. Two parts per target:
#   1. extents: every mapped sensitive-file extent (map_files) is zeroed.
#   2. scan: the raw device is read end to end and every block where a key
#      signature matches is zeroed: live files, deleted files, journal, swap,
#      old copies. A false positive only zeroes early a block zeroize clears
#      anyway, so the patterns are greedy.
# Zeros go only to 64 KiB blocks that are not already zero (norm_chunk), so
# thin storage allocates nothing; each struck range is read back as zero.
# Why these targets: NIST SP 800-88r2 and IEEE 2883 accept cryptographic erase
# because destroying the key is decisive; keys and the headers that wrap them
# are the data whose early loss makes the rest worthless. See docs/STRIKE.md.
#
# name<TAB>span in KiB from the match's 64 KiB block<TAB>ERE (LC_ALL=C bytes)
STRIKE_BUILTIN=(
  $'pem-private\t128\t-----(BEGIN|END) [A-Z0-9 ]{0,32}PRIVATE KEY( BLOCK)?-----'
  $'openssh-key\t128\topenssh-key-v1|b3BlbnNzaC1rZXktdjE'
  $'putty-key\t128\tPuTTY-User-Key-File-[0-9]+:'
  $'age-key\t64\tAGE-SECRET-KEY-1[0-9A-Z]{20}'
  $'shadow-hash\t64\t:\\$(1|2[abxy]|5|6|7|y|gy|sha1|md5)\\$[./A-Za-z0-9]{2,}\\$'
  $'wireguard-key\t64\tPrivateKey[[:blank:]]*=[[:blank:]]*[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]='
  $'tailscale-key\t128\tprivkey:[0-9a-f]{64}|"PrivateNodeKey"'
  $'jwk-private\t64\t"d"[[:blank:]]*:[[:blank:]]*"[A-Za-z0-9_-]{40,}"'
  $'luks-header\t16384\tLUKS\xba\xbe|SKUL\xba\xbe'
  $'aws-key\t64\t(AKIA|ASIA)[A-Z0-9]{16}|aws_secret_access_key'
  $'url-credential\t64\t[a-z][a-z0-9+.-]{1,15}://[^:/@[:blank:]"]{1,64}:[^@/[:blank:]"]{1,128}@'
)
STRIKE_OV=4096              # bytes each scan read runs past its chunk: a match
                            # that starts in a chunk is always found whole
STRIKE_TAIL=4096            # a struck range always runs this far past the match
                            # (a key line or PEM body crossing a block edge)
STRIKE_NAME=() STRIKE_SPAN=() STRIKE_RE=()
# A real LUKS header starts a device, a partition or the LUKS2 backup header
# area, all on 4 KiB boundaries; cryptsetup's and blkid's libraries carry the
# same magic as a constant at arbitrary offsets. A match off the boundary is
# counted, not struck.
declare -A STRIKE_ALIGN=([luks-header]=4096)
declare -A DEV_STRIKE=()    # device -> receipt sentence
STRIKE_FAILED=0

# strike_load: the built-in patterns plus $SLAG_RAM/strike-user.tsv (staged
# from --strike-patterns). Writes the combined ERE list for grep.
strike_load() {
  local line name span re src=() f="${SLAG_RAM:-}/strike-user.tsv"
  STRIKE_NAME=() STRIKE_SPAN=() STRIKE_RE=()
  src=("${STRIKE_BUILTIN[@]}")
  if [[ -n ${SLAG_RAM:-} && -s $f ]]; then
    while IFS= read -r line; do src+=("$line"); done < "$f"
  fi
  for line in "${src[@]}"; do
    [[ -z $line || $line == '#'* ]] && continue
    IFS=$'\t' read -r name span re <<< "$line"
    STRIKE_NAME+=("$name") STRIKE_SPAN+=("$span") STRIKE_RE+=("$re")
  done
}

# strike_check_file FILE: --strike-patterns, checked before anything starts.
# Lines: NAME<TAB>SPAN_KIB<TAB>ERE; # comments and blank lines skipped.
strike_check_file() {
  local f="$1" line name span re n=0 ln=0 rc
  [[ -r $f ]] || die "--strike-patterns: cannot read $f"
  while IFS= read -r line || [[ -n $line ]]; do
    ln=$(( ln + 1 ))
    [[ -z $line || $line == '#'* ]] && continue
    IFS=$'\t' read -r name span re <<< "$line"
    [[ $name =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "--strike-patterns $f:$ln: NAME must be 1-32 of A-Z a-z 0-9 _ . -"
    [[ $span =~ ^[1-9][0-9]{0,5}$ ]] && (( span % 64 == 0 )) \
      || die "--strike-patterns $f:$ln: SPAN must be a multiple of 64 (KiB), at most 999936"
    [[ -n $re ]] || die "--strike-patterns $f:$ln: no pattern after NAME<TAB>SPAN<TAB>"
    rc=0; printf '%s\n' "$re" | LC_ALL=C grep -E -f - /dev/null >/dev/null 2>&1 || rc=$?
    [[ $rc -le 1 ]] || die "--strike-patterns $f:$ln: grep -E rejects the pattern"
    n=$(( n + 1 ))
  done < "$f"
  (( n > 0 )) || die "--strike-patterns: $f has no patterns"
  STRIKE_FILE_N=$n
}

stage_strike() {
  [[ -n $STRIKE_FILE ]] || return 0
  grep -v -e '^#' -e '^$' -- "$STRIKE_FILE" > "$1/strike-user.tsv" || die "cannot copy $STRIKE_FILE into $1"
  log "strike: $STRIKE_FILE_N added patterns from $STRIKE_FILE"
}

# strike_label MATCH: the first pattern MATCH satisfies (bash ERE, C locale)
# as SL_NAME and SL_SPAN (KiB). A match bash's regex engine does not label
# (grep and bash can differ on an added pattern) is "other", 128 KiB.
strike_label() {
  local i
  for i in "${!STRIKE_RE[@]}"; do
    [[ $1 =~ ${STRIKE_RE[$i]} ]] && { SL_NAME=${STRIKE_NAME[$i]} SL_SPAN=${STRIKE_SPAN[$i]}; SL_ALIGN=${STRIKE_ALIGN[$SL_NAME]:-1}; return 0; }
  done
  SL_NAME=other SL_SPAN=128 SL_ALIGN=1
}

# strike_zero DEV OFF LEN: zero the nonzero 64 KiB blocks of the range (read,
# compare, write: norm_chunk), then read the whole range back. Returns 1 on
# any read, write or verify failure.
strike_zero() {
  local dev="$1" off="$2" len="$3" o l rc=0 w0=$NORM_WRITTEN
  for (( o = off; o < off + len; o += NORM_CHUNK )); do
    l=$(( off + len - o < NORM_CHUNK ? off + len - o : NORM_CHUNK ))
    norm_chunk "$dev" "$o" "$l" || rc=1
  done
  ST_WRITTEN=$(( ST_WRITTEN + NORM_WRITTEN - w0 ))
  if ! zero_read "$dev" "$off" "$len" "$NORM_IFLAG"; then
    rc=1
    (( ST_BAD < 5 )) && err "strike: range at byte offset $off on $dev does not read zero after the strike: $(zr_reason "$off")"
  fi
  if [[ $rc -ne 0 ]]; then ST_BAD=$(( ST_BAD + 1 )); return 1; fi
  ST_RANGES=$(( ST_RANGES + 1 )); ST_BYTES=$(( ST_BYTES + len ))
  return 0
}

# Pending range of the scan: hits are merged while their spans touch.
st_flush() {
  (( ST_PE > ST_PA )) || return 0
  strike_zero "$1" "$ST_PA" $(( ST_PE - ST_PA )) || true
  ST_DONE=$ST_PE ST_PA=0 ST_PE=0
}

# strike_device DEV [SIZE]: the two parts; SIZE is for the self-test's file.
strike_device() {
  local dev="$1" sz="${2:-}" f="${SLAG_RAM:-}/extents.tsv" d off len path a e rlen rc n line rel h span grc
  local ext_n=0 ext_bad=0 last pct labels="" i ul=""
  local -A hits=() unal=()
  local LC_ALL=C
  ST_WRITTEN=0 ST_RANGES=0 ST_BYTES=0 ST_BAD=0 ST_HITS=0 ST_RERR=0 ST_PA=0 ST_PE=0 ST_DONE=0 ST_GERR=0 ST_UNALIGNED=0
  if [[ $DRY_RUN -eq 1 ]]; then
    DEV_STRIKE[$dev]="strike (dry-run): nothing read or written"
    record "$dev" strike ok "would zero the mapped sensitive-file extents, then read every byte and zero each 64 KiB block where one of ${#STRIKE_RE[@]} key signatures matches (${STRIKE_NAME[*]})"
    return 0
  fi
  if [[ -z $sz ]] && ! sz=$(dev_size "$dev"); then
    DEV_STRIKE[$dev]="strike FAILED: cannot read the device size"
    record "$dev" strike failed "cannot read the device size"; return 1
  fi
  NORM_READ=0 NORM_WRITTEN=0 NORM_CHUNKS=0 NORM_RUNS=0 NORM_SUBS=0 NORM_ERR=""
  NORM_RERRS=0 NORM_WERRS=0 NORM_NERR=0 NORM_RERR1="" NORM_WERR1=""
  NORM_CHUNKF=$SLAG_RAM/strike.z NORM_ERRF=$SLAG_RAM/strike.err NORM_CMPF=$SLAG_RAM/strike.cmp
  local chunkf=$SLAG_RAM/strike.chunk ref=$SLAG_RAM/strike.re
  printf '%s\n' "${STRIKE_RE[@]}" > "$ref" || { record "$dev" strike failed "cannot write $ref"; return 1; }

  # 1. known files
  if [[ -r $f ]]; then
    while IFS=$'\t' read -r d off len path; do
      [[ $d == "$dev" ]] || continue
      a=$(( off / NORM_SUB * NORM_SUB )); e=$(( (off + len + NORM_SUB - 1) / NORM_SUB * NORM_SUB ))
      (( e > sz )) && e=$sz
      (( a < e )) || continue
      ext_n=$(( ext_n + 1 ))
      strike_zero "$dev" "$a" $(( e - a )) || ext_bad=$(( ext_bad + 1 ))
    done < "$f"
    (( ext_n == 0 )) || log "strike $dev: $ext_n mapped file extents zeroed ($ext_bad failed)"
  fi

  # 2. raw scan
  if ! have grep; then
    record "$dev" strike-scan unsupported "grep not available; only mapped file extents were struck"
  else
    log "strike $dev: scanning all $sz bytes for ${#STRIKE_RE[@]} key signatures; zeroing each match's block now"
    last=$SECONDS
    for (( off = 0; off < sz; off += NORM_CHUNK )); do
      len=$(( sz - off < NORM_CHUNK ? sz - off : NORM_CHUNK ))
      rlen=$(( sz - off < NORM_CHUNK + STRIKE_OV ? sz - off : NORM_CHUNK + STRIKE_OV ))
      rc=0
      dd if="$dev" of="$chunkf" bs=16M iflag="${NORM_IFLAG:+$NORM_IFLAG,}skip_bytes,count_bytes,fullblock" \
        skip="$off" count="$rlen" </dev/null 2> "$NORM_ERRF" || rc=$?
      n=$(dd_bytes "$NORM_ERRF")
      if [[ $rc -ne 0 || $n != "$rlen" ]]; then
        # Unknown data: zero the chunk now (zeroize would, later).
        ST_RERR=$(( ST_RERR + 1 ))
        (( ST_RERR <= 5 )) && err "strike: read error at byte offset $off on $dev (dd exit $rc, $n of $rlen bytes); zeroing the chunk"
        st_flush "$dev"; a=$off; (( a < ST_DONE )) && a=$ST_DONE
        (( a < off + len )) && { strike_zero "$dev" "$a" $(( off + len - a )) || true; ST_DONE=$(( off + len )); }
        continue
      fi
      norm_cmp "$chunkf" 0 "$rlen"
      if [[ $NC_RC != 0 ]]; then
        grc=0
        # Only the offset leaves this loop; the matched bytes are dropped.
        while IFS= read -r line; do
          [[ $line == rc:* ]] && { grc=${line#rc:}; continue; }
          rel=${line%%:*}
          [[ $rel =~ ^[0-9]+$ ]] && (( rel < len )) || continue
          strike_label "${line#*:}"; line=""
          h=$(( off + rel ))
          if (( h % SL_ALIGN != 0 )); then
            ST_UNALIGNED=$(( ST_UNALIGNED + 1 )); unal[$SL_NAME]=$(( ${unal[$SL_NAME]:-0} + 1 )); continue
          fi
          ST_HITS=$(( ST_HITS + 1 )); hits[$SL_NAME]=$(( ${hits[$SL_NAME]:-0} + 1 ))
          a=$(( h / NORM_SUB * NORM_SUB ))
          span=$(( SL_SPAN * 1024 ))
          (( span < NORM_SUB )) && span=$NORM_SUB
          e=$(( a + span ))
          rel=$(( (h + STRIKE_TAIL + NORM_SUB - 1) / NORM_SUB * NORM_SUB )); (( rel > e )) && e=$rel
          (( e > sz )) && e=$sz
          (( a < ST_DONE )) && a=$ST_DONE
          (( a < e )) || continue
          if (( a <= ST_PE && ST_PE > ST_PA )); then
            (( e > ST_PE )) && ST_PE=$e
          else
            st_flush "$dev"; ST_PA=$a ST_PE=$e
          fi
        done < <(LC_ALL=C grep -a -b -o -E -f "$ref" -- "$chunkf" 2>/dev/null; echo "rc:$?")
        if [[ $grc -gt 1 ]]; then
          # grep could not scan it: zero the chunk now (zeroize would, later).
          ST_GERR=$(( ST_GERR + 1 ))
          (( ST_GERR <= 5 )) && err "strike: grep failed (exit $grc) on the chunk at byte offset $off of $dev; zeroing the chunk"
          st_flush "$dev"; a=$off; (( a < ST_DONE )) && a=$ST_DONE
          (( a < off + len )) && { strike_zero "$dev" "$a" $(( off + len - a )) || true; ST_DONE=$(( off + len )); }
        fi
        # The pending range is flushed once the scan has passed its end.
        (( ST_PE > ST_PA && ST_PE <= off + len )) && st_flush "$dev"
      fi
      if (( SECONDS - last >= NORM_PROGRESS_SECS )); then
        last=$SECONDS; pct=$(( (off + len) * 100 / sz ))
        log "  strike $dev: $pct% scanned, $ST_HITS signature hits, $ST_RANGES ranges zeroed"
      fi
    done
    st_flush "$dev"
    : > "$chunkf"
  fi
  : > "$NORM_CHUNKF"
  for i in "${!hits[@]}"; do labels+="${labels:+, }$i ${hits[$i]}"; done
  local s
  s="$ext_n mapped file extents; scan: $ST_HITS signature hits${labels:+ ($labels)}; $ST_RANGES ranges, $(commas "$ST_BYTES") bytes struck and read back zero; $(commas "$ST_WRITTEN") bytes written"
  (( ST_RERR == 0 )) || s+="; $ST_RERR unreadable chunks zeroed whole"
  (( ST_GERR == 0 )) || s+="; $ST_GERR chunks grep could not scan, zeroed whole"
  for i in "${!unal[@]}"; do ul+="${ul:+, }$i ${unal[$i]}"; done
  (( ST_UNALIGNED == 0 )) || s+="; $ST_UNALIGNED matches off their alignment not struck ($ul)"
  if (( ST_BAD > 0 || NORM_WERRS > 0 )); then
    DEV_STRIKE[$dev]="strike FAILED: $ST_BAD ranges did not read back zero; $s"
    record "$dev" strike failed "$ST_BAD ranges did not read back zero; $s"
    return 1
  fi
  DEV_STRIKE[$dev]="struck before the wipe: $s"
  record "$dev" strike ok "$s"
  return 0
}

# ---- reclaim ---------------------------------------------------------------

# Only with --reclaim, after every target has verified. Gives the space back
# to thin storage and TRIMs an SSD; the second verify then proves the device
# still reads zero.
discard_device() {
  local dev="$1" name="$2" out rc=0 f=()
  if ! have blkdiscard; then record "$dev" "$name" unsupported "blkdiscard not available"; return 0; fi
  out=$(blkdiscard --help 2>&1) || true
  [[ $out == *--force* ]] && f=(-f)
  if [[ $DRY_RUN -eq 1 ]]; then step "$dev" "$name" blkdiscard "${f[@]}" "$dev"; return 0; fi
  out=$(run blkdiscard "${f[@]}" "$dev" 2>&1 </dev/null) || rc=$?
  if [[ $rc -eq 0 ]]; then record "$dev" "$name" ok "discarded $(lsfield "$dev" SIZE)"; return 0; fi
  if [[ $out == *"not supported"* ]]; then record "$dev" "$name" unsupported "$(lastline "$out")"
  else record "$dev" "$name" failed "rc=$rc: $(lastline "$out")"; fi
  return 0
}

# In-place the disk is fenced read-only, which blkdiscard cannot get past: lift
# the fence for the discard only. The second verify runs after the re-fence,
# so a write that slipped in meanwhile fails it.
reclaim_device() {
  local dev="$1" rc=0
  log "=== reclaim: discard + second verify: $dev ==="
  if [[ $IN_PLACE -eq 1 ]]; then
    step "$dev" reclaim-unfence blockdev --setrw "$dev" || true
  fi
  discard_device "$dev" reclaim-discard
  if [[ $IN_PLACE -eq 1 ]]; then fence_disk "$dev" || rc=1; fi
  if [[ $VERIFY_SAMPLE -gt 0 ]]; then
    verify_sample "$dev" "$VERIFY_SAMPLE" reclaim || rc=1
  else
    verify_full "$dev" reclaim || rc=1
  fi
  return "$rc"
}

# ---- quiesce ---------------------------------------------------------------

pid_unit() {
  local line path=""
  { while IFS= read -r line; do
      case "$line" in 0::*|*:name=systemd:*) path=${line#*:}; path=${path#*:} ;; esac
    done < "/proc/$1/cgroup"; } 2>/dev/null || true
  # Template instances sit in a sub-slice: system-postgresql.slice/postgresql@16-main.service
  if [[ $path =~ ^/system\.slice/([^/]+\.slice/)*([^/]+\.service)(/|$) ]]; then
    printf '%s' "${BASH_REMATCH[2]}"
  elif [[ $path =~ ^/user\.slice/.*/(user@[0-9]+\.service) ]]; then
    printf '%s' "${BASH_REMATCH[1]}"
  else
    printf '%s' "${path:-?}"
  fi
}

# Processes holding a file open for writing (O_WRONLY/O_RDWR) on a target
# mount, grouped by systemd unit. An fd's mnt_id is only meaningful in the
# process's own mount namespace (PrivateTmp, ProtectSystem, ... give a
# service its own copies of the mounts, with other ids), so each namespace's
# mountinfo is scanned once, keyed by the first pid seen in it.
find_writers() {
  local p pid f key val flags mnt hit unit rep r k
  local reps=(self) ids=()
  WRITER_UNITS=() WRITER_OTHER=()
  MNT_SCAN=()
  for k in "${!TGT_MNT[@]}"; do MNT_SCAN[self:$k]=${TGT_MNT[$k]}; done
  for p in /proc/[0-9]*; do
    pid=${p#/proc/}
    [[ $pid == "$$" || $pid == 1 ]] && continue
    hit=0 ids=()
    for f in "$p"/fdinfo/*; do
      flags="" mnt=""
      { while read -r key val; do
          case "$key" in flags:) flags=$val ;; mnt_id:) mnt=$val ;; esac
        done < "$f"; } 2>/dev/null || continue
      [[ $flags =~ ^[0-7]+$ && -n $mnt ]] || continue
      (( (8#$flags & 3) != 0 )) && ids+=("$mnt")
    done
    [[ ${#ids[@]} -gt 0 ]] || continue
    rep=""
    for r in "${reps[@]}"; do
      [[ $p/ns/mnt -ef /proc/$r/ns/mnt ]] && { rep=$r; break; }
    done
    if [[ -z $rep ]]; then
      { scan_mounts "$p/mountinfo" "$pid:"; } 2>/dev/null || continue
      rep=$pid; reps+=("$pid")
    fi
    for mnt in "${ids[@]}"; do
      [[ -n ${MNT_SCAN[$rep:$mnt]:-} ]] && { hit=1; break; }
    done
    [[ $hit -eq 1 ]] || continue
    unit=$(pid_unit "$pid")
    if [[ $unit == *.service ]]; then WRITER_UNITS[$unit]+=" $pid"
    else WRITER_OTHER[$pid]=$unit; fi
  done
  return 0
}

# Tests the superblock flag (first super option, after " - "), not the
# per-mount flag in field 6: sysrq u sets only SB_RDONLY, and a read-only
# bind of a writable superblock still writes to the device.
target_mounts_rw() {
  local id mp rest sopts still=()
  while read -r id _ _ _ mp _ rest; do
    [[ -n ${TGT_MNT[$id]:-} ]] || continue
    sopts=" $rest"; sopts=${sopts#* - }; read -r _ _ sopts _ <<< "$sopts"
    [[ ,$sopts, == *,ro,* ]] || still+=("$mp")
  done < /proc/self/mountinfo
  printf '%s' "${still[*]}"
}

# The mount id holding PATH: the longest mountpoint prefix, the later mount
# on a tie. /proc/swaps and mountinfo escape names the same way.
mount_id_of() {
  local path="$1" id mp best="" blen=-1
  while read -r id _ _ _ mp _; do
    if [[ $mp == / || $path == "$mp" || $path == "$mp/"* ]] && (( ${#mp} >= blen )); then
      best=$id blen=${#mp}
    fi
  done < /proc/self/mountinfo
  printf '%s' "$best"
}

# Active swap on a target keeps taking page-outs after the verify; neither
# the read-only remount nor zeroize stops it.
check_target_swap() {
  local f _r majmin real on=() rc=0
  if [[ $DRY_RUN -eq 1 ]]; then
    record host swap-check ok "fails the run if swap stays active on a target"
    return 0
  fi
  { while read -r f _r; do
      [[ $f == Filename ]] && continue
      if [[ -b $f ]]; then
        real=$(realpath -- "$f") || real=$f
        majmin=""
        { read -r majmin < "/sys/class/block/${real##*/}/dev"; } 2>/dev/null || true
        if [[ -n ${TGT_DEVS[$real]:-} || ( -n $majmin && -n ${TGT_DEVS[$majmin]:-} ) ]]; then on+=("$f"); fi
      elif [[ -n ${TGT_MNT[$(mount_id_of "$f")]:-} ]]; then
        on+=("$f")
      fi
    done < /proc/swaps; } 2>/dev/null || rc=1
  if [[ $rc -ne 0 ]]; then
    QUIESCE_FAILED=1
    record host swap-check failed "cannot read /proc/swaps"
  elif [[ ${#on[@]} -gt 0 ]]; then
    QUIESCE_FAILED=1
    record host swap-check failed "swap still active on a target: ${on[*]}"
  else
    record host swap-check ok "no active swap on the targets"
  fi
  return 0
}

# Block devices stacked on the targets can write to a target after its
# verify: md resync and superblock/bitmap updates, thin/cache metadata,
# bcache writeback. Stop each one that is not in use, top of the stack
# first. One that stays active fails the run, unless it is a dm table of
# only linear/striped/crypt/zero/error targets, which writes nothing of its
# own and carries a filesystem that is now read-only.
stop_holders() {
  local i h name left=()
  collect_holders
  if [[ ${#HOLDERS[@]} -eq 0 ]]; then
    record host holders ok "no md/dm/bcache device is built on the targets"
    return 0
  fi
  log "block devices built on the targets: ${HOLDERS[*]}"
  for (( i=${#HOLDERS[@]}-1; i>=0; i-- )); do
    h=${HOLDERS[i]}
    if [[ $DRY_RUN -eq 0 ]] && holder_busy "$h"; then
      record host "stop:$h" skipped "in use: $HOLDER_WHY"
      continue
    fi
    case "$h" in
      md*)
        if have mdadm; then step host "stop:$h" mdadm --stop "/dev/$h" || true
        else record host "stop:$h" unsupported "mdadm not available"; fi ;;
      dm-*)
        name=""; { read -r name < "/sys/class/block/$h/dm/name"; } 2>/dev/null || true
        if have dmsetup; then step host "stop:$h${name:+ ($name)}" dmsetup remove -- "${name:-/dev/$h}" || true
        else record host "stop:$h" unsupported "dmsetup not available"; fi ;;
      bcache*)
        if [[ $DRY_RUN -eq 1 ]]; then
          printf '%s[dry-run]%s echo 1 > /sys/block/%s/bcache/stop\n' "$c_ylw" "$c_rst" "$h" >&2
          record host "stop:$h" ok
        elif { printf 1 > "/sys/block/$h/bcache/stop"; } 2>/dev/null; then
          record host "stop:$h" ok "bcache stop requested"
        else
          record host "stop:$h" failed "cannot write /sys/block/$h/bcache/stop"
        fi ;;
      *)
        record host "stop:$h" unsupported "no stop method for $h" ;;
    esac
  done
  [[ $DRY_RUN -eq 1 ]] && return 0
  sleep 1      # bcache and md tear down asynchronously
  for h in "${HOLDERS[@]}"; do
    [[ -e /sys/class/block/$h ]] || continue
    if dm_passive "$h"; then
      record host "active:$h" ok "still active, but a plain $DM_TYPES mapping writes nothing of its own"
    else
      left+=("$h")
    fi
  done
  if [[ ${#left[@]} -gt 0 ]]; then
    QUIESCE_FAILED=1
    record host holders failed "still active and can write to a target after its verify: ${left[*]} (in use? wipe from rescue mode)"
  else
    record host holders ok "nothing built on the targets can write to them"
  fi
  return 0
}

# holder_busy KNAME: true if the device still has holders or carries a
# mount; stopping it would only fail with EBUSY. Sets HOLDER_WHY.
holder_busy() {
  local h="$1" x majmin="" name="" mm mp rest src
  HOLDER_WHY=""
  for x in "/sys/class/block/$h/holders/"*; do
    [[ -e $x ]] && { HOLDER_WHY="held by ${x##*/}"; return 0; }
  done
  { read -r majmin < "/sys/class/block/$h/dev"; } 2>/dev/null || true
  { read -r name < "/sys/class/block/$h/dm/name"; } 2>/dev/null || true
  while read -r _ _ mm _ mp _ rest; do
    src=" $rest"; src=${src#* - }; read -r _ src _ <<< "$src"
    if [[ $mm == "$majmin" || $src == "/dev/$h" || ( -n $name && $src == "/dev/mapper/$name" ) ]]; then
      HOLDER_WHY="mounted at $mp"; return 0
    fi
  done < /proc/self/mountinfo
  return 1
}

# dm_passive KNAME: true if the dm device's table uses only targets that
# write nothing but what they are given. Sets DM_TYPES.
dm_passive() {
  local name out t
  DM_TYPES=""
  [[ $1 == dm-* ]] && have dmsetup || return 1
  name=""; { read -r name < "/sys/class/block/$1/dm/name"; } 2>/dev/null || true
  out=$(dmsetup table -- "${name:-/dev/$1}" 2>/dev/null) || return 1
  while read -r _ _ t _; do
    case "$t" in
      linear|striped|crypt|zero|error) [[ " $DM_TYPES " == *" $t "* ]] || DM_TYPES+="${DM_TYPES:+ }$t" ;;
      *) return 1 ;;
    esac
  done <<< "$out"
  [[ -n $DM_TYPES ]]
}

sysrq() {
  if [[ $DRY_RUN -eq 1 ]]; then
    printf '%s[dry-run]%s echo %s > /proc/sysrq-trigger\n' "$c_ylw" "$c_rst" "$1" >&2
    return 0
  fi
  _tolog '[exec]' "echo $1 > /proc/sysrq-trigger"
  printf '%s' "$1" > /proc/sysrq-trigger
}

# is_target KNAME: true if KNAME is one of the whole target disks.
is_target() {
  local t
  for t in "${TARGETS[@]}"; do [[ ${t##*/} == "$1" ]] && return 0; done
  return 1
}

# sysrq u sets only SB_RDONLY. ext4's error path still commits its superblock
# whenever the block device itself is writable (bdev_read_only), so once the
# disk is zeroed any lookup on the root fs (a logout, timesyncd) rewrites 1 KiB
# of the target after the verify. Set the partition or dm device under each
# target mount read-only; the whole disk stays writable for zeroize. A
# filesystem directly on a whole target disk cannot be fenced here, so
# wipe_device sets every in-place target read-only after zeroize.
fence_mounts() {
  local id majmin mp rest src k ro
  local -A seen=()
  while read -r id _ majmin _ mp _ rest; do
    [[ -n ${TGT_MNT[$id]:-} ]] || continue
    src=" $rest"; src=${src#* - }; read -r _ src _ <<< "$src"
    k=$(mount_kname "$majmin" "$src")
    if [[ -z $k ]]; then
      QUIESCE_FAILED=1
      record host "fence:$mp" failed "cannot find the block device under $mp ($majmin $src)"
      continue
    fi
    [[ -z ${seen[$k]:-} ]] || continue
    seen[$k]=1
    if is_target "$k"; then
      WHOLE_FS[/dev/$k]=$mp
      record host "fence:$k" skipped "filesystem directly on the whole target disk ($mp); set read-only after zeroize instead"
      continue
    fi
    step host "fence:$k" blockdev --setro "/dev/$k" || { QUIESCE_FAILED=1; continue; }
    [[ $DRY_RUN -eq 1 ]] && continue
    ro=$(blockdev --getro "/dev/$k" 2>/dev/null </dev/null) || ro=""
    if [[ $(trim "$ro") != 1 ]]; then
      QUIESCE_FAILED=1
      record host "fence:$k" failed "/dev/$k still writable after blockdev --setro (getro=${ro:-?})"
    fi
  done < /proc/self/mountinfo
  return 0
}

# Swap off, stop services writing to the targets, sync, force every
# block-backed filesystem read-only, set the devices under the target mounts
# read-only, then stop md/dm/bcache devices built on the targets, so nothing
# (swap-out, journal commit, dirty page writeback, md resync, ext4 error
# superblock) lands on a target after zeroize. No `systemctl isolate`:
# it can stop sshd and leave the console waiting for a root password.
quiesce() {
  local unit pid self still w=0
  log "quiescing: swap off, stop writers on the targets, sync, emergency read-only remount, stop md/dm"
  collect_target_tree
  collect_target_mounts
  if have swapoff; then step host swapoff swapoff -a || true
  else record host swapoff unsupported "swapoff not available"; fi
  check_target_swap
  if [[ ${#TGT_MNT[@]} -eq 0 ]]; then
    IN_PLACE=0
    record host stop-writers skipped "no filesystem on the targets is mounted"
    step host sync sync || true
    record host remount-ro skipped "no filesystem on the targets is mounted"
    stop_holders
    return 0
  fi
  IN_PLACE=1
  log "mounted from the targets: ${TGT_MNT[*]}"
  find_writers
  self=$(pid_unit $$)
  for unit in "${!WRITER_UNITS[@]}"; do
    w=1
    case "$unit" in
      "$self") ;;
      systemd-journald.service)
        step host "journald-relinquish" journalctl --relinquish-var || true ;;
      *)
        step host "stop:$unit" timeout 120 systemctl stop -- "$unit" || true ;;
    esac
  done
  for pid in "${!WRITER_OTHER[@]}"; do
    w=1
    record host "writer:pid-$pid" skipped "not a system service (${WRITER_OTHER[$pid]}); the read-only remount cuts it off"
  done
  [[ $w -eq 1 ]] || record host stop-writers ok "no process holds a file open for writing on the targets"
  step host sync sync || true
  # Writes to /proc/sysrq-trigger work whatever /proc/sys/kernel/sysrq says
  # (that mask only gates the keyboard), so it is left alone.
  if ! sysrq s || ! sysrq u; then
    QUIESCE_FAILED=1
    record host remount-ro failed "cannot write /proc/sysrq-trigger"
    stop_holders
    return 0
  fi
  if [[ $DRY_RUN -eq 1 ]]; then record host remount-ro ok "${TGT_MNT[*]}"; fence_mounts; stop_holders; return 0; fi
  # sysrq u is queued to a kernel worker; give it time to finish.
  for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
    still=$(target_mounts_rw)
    [[ -n $still ]] || break
    sleep 1
  done
  if [[ -z $still ]]; then
    record host remount-ro ok "read-only: ${TGT_MNT[*]}"
    fence_mounts
  else
    QUIESCE_FAILED=1
    record host remount-ro failed "still writable after 15s: $still"
  fi
  stop_holders
  return 0
}

# ---- receipt ---------------------------------------------------------------

# The receipt's literal statement of what zeroize did on DEV.
norm_sentence() {
  local d="$1" st rd wr why v
  read -r st rd wr why <<< "${DEV_NORM[$d]:-none}"
  case "$st" in
    ok)
      case "${DEV_VOK[$d]:-}" in
        full)    v="every block verified zero." ;;
        sampled) v="sampled blocks verified zero (unsampled blocks unproven)." ;;
        *)       v="verify: ${DEV_VERIFY[$d]:-not run}." ;;
      esac
      printf 'zeroized: %s bytes read, %s bytes written where a block was not already zero; %s' \
        "$(commas "$rd")" "$(commas "$wr")" "$v" ;;
    FAILED)  printf 'zeroize FAILED: %s; %s bytes read without error, %s bytes written.' "$why" "$(commas "$rd")" "$(commas "$wr")" ;;
    dry-run) printf 'zeroize (dry-run): nothing read or written.' ;;
    *)       printf 'zeroize: not run.' ;;
  esac
}

build_receipt() {
  local ended="$1" verdict="$2" d r dev st out det mode
  if [[ $VERIFY_SAMPLE -gt 0 ]]; then mode="sampled (first + last + $VERIFY_SAMPLE random 1 MiB blocks per device; proves only those blocks)"
  else mode="full read-back of every byte"; fi
  echo "===================== SLAG WIPE RECEIPT ====================="
  echo "tool:     $PROG $VERSION"
  echo "host:     ${HOSTNAME:-?}"
  echo "started:  $STARTED"
  echo "finished: $ended"
  if [[ $DRY_RUN -eq 1 ]]; then echo "run:      DRY RUN (nothing written, erased or read)"
  elif [[ $IN_PLACE -eq 1 ]]; then echo "run:      in-place (re-exec from tmpfs ${SLAG_RAM:-?})"
  else echo "run:      rescue (no target filesystem mounted), from tmpfs ${SLAG_RAM:-?}"; fi
  echo "zeroize:  conditional zero pass: every block read (direct I/O, 16 MiB chunks); zeros"
  echo "          written only to 64 KiB blocks not already zero, in place; no discard before it"
  echo "verify:   $mode"
  if [[ $RECLAIM -eq 1 ]]; then
    echo "reclaim:  discard every target after all verified, then verify again (a device that does"
    echo "          not read zeros after the discard FAILS)"
  else
    echo "reclaim:  not requested; nothing was discarded"
  fi
  echo "files:    $(fm_summary)"
  if [[ $STRIKE -eq 1 ]]; then
    echo "strike:   before any erase: mapped secret-file extents, then every byte read and each 64 KiB"
    echo "          block where a key signature matched (${STRIKE_NAME[*]}) zeroed and read back"
    echo "          zero. Secrets first if the run is cut short; the verify below is the proof."
  else
    echo "strike:   not run (--no-strike)"
  fi
  if [[ -n $PATTERN ]]; then
    echo "pattern:  $PATTERN = $PAT_STD"
    echo "          ${#PAT_PASSES[@]} passes (${PAT_PASSES[*]}; R = random) over every byte, after every target"
    echo "          verified zero; the last pass is read back in full and its SHA-256 must equal that"
    echo "          of the bytes written. The disks keep the last pass, not zeros."
  fi
  [[ -z $SERVE_TOKEN ]] || echo "served:   $SERVE_URL (plain HTTP; RECEIPT.txt, RECEIPT.txt.sha256, VERDICT, slag.log)"
  if [[ -n $SEND_ON ]]; then
    echo "sent to:  POST $SEND_SHOW, once, after this receipt was written;"
    echo "          the outcome is the line after the closing line in the local RECEIPT.txt"
  fi
  if [[ $DRY_RUN -eq 0 && $IN_PLACE -eq 1 ]]; then
    echo "caveat:   in-place: the running kernel still holds the target filesystems (read-only,"
    echo "          on devices set read-only). This receipt covers the disk as of the re-check"
    echo "          ${SETTLE_SECS}s after the verify; a write the kernel makes after that is not covered."
  fi
  if [[ ${#WHOLE_FS[@]} -gt 0 ]]; then
    echo "whole-disk: --in-place-whole-disk: a filesystem sat directly on ${!WHOLE_FS[*]} (no partition"
    echo "          table), so that disk stayed writable through zeroize; its signature blocks were"
    echo "          zeroed again just before it was set read-only. A rescue system avoids this."
  fi
  for d in "${TARGETS[@]}"; do
    echo
    echo "device $d"
    echo "  model=${DEV_MODEL[$d]:-?}  serial=${DEV_SERIAL[$d]:-?}  size=${DEV_SIZE[$d]:-?} bytes"
    for r in "${RES[@]}"; do
      IFS=$'\t' read -r dev st out det <<< "$r"
      [[ $dev == "$d" ]] || continue
      printf '  %-24s %-12s %s\n' "$st" "$out" "$det"
    done
    echo "  $(norm_sentence "$d")"
    echo "  verify:  ${DEV_VERIFY[$d]:-not run}"
    echo "  files:   ${DEV_EXT[$d]:-not run}"
    echo "  strike:  ${DEV_STRIKE[$d]:-not run}"
    [[ -z $PATTERN ]] || echo "  pattern: ${DEV_PATTERN[$d]:-not run}"
    echo "  device verdict: ${DEV_VERDICT[$d]:-FAIL}"
  done
  echo
  echo "host"
  for r in "${RES[@]}"; do
    IFS=$'\t' read -r dev st out det <<< "$r"
    [[ $dev == host ]] || continue
    printf '  %-24s %-12s %s\n' "$st" "$out" "$det"
  done
  echo
  echo "VERDICT: $verdict"
  echo "=============================================================="
}

# To stderr, to the console (it outlives the SSH session), to a tmpfs file and
# into the log the SSH follower is reading.
emit_receipt() {
  local text="$1"
  printf '\n%s\n' "$text" >&2
  [[ $DRY_RUN -eq 1 ]] && return 0
  { printf '%s\n' "$text" > "$SLAG_RAM/RECEIPT.txt"; } 2>/dev/null \
    || warn "could not write $SLAG_RAM/RECEIPT.txt"
  { printf '%s\n' "$text" >> "$SLAG_LOG"; } 2>/dev/null || true
  to_console $'\n'"$text" || warn "could not write the receipt to the console"
  log "receipt saved: $SLAG_RAM/RECEIPT.txt"
  return 0
}

# ---- the wipe itself (detached, running from tmpfs) ------------------------

# In-place, after zeroize and before the verify: a filesystem directly
# on the disk is fenced here (see fence_mounts), and on kernels 5.12+ the
# disk's read-only flag also covers its partitions. A write that slips in
# before this lands before the verify reads it.
fence_disk() {
  local dev="$1" ro
  step "$dev" fence blockdev --setro "$dev" || return 1
  [[ $DRY_RUN -eq 1 ]] && return 0
  ro=$(blockdev --getro "$dev" 2>/dev/null) || ro=""
  [[ $(trim "$ro") == 1 ]] && return 0
  record "$dev" fence failed "still writable after blockdev --setro (getro=${ro:-?})"
  return 1
}

# Runs after hw_erase_all, so no later hardware erase can touch a device
# this has verified. No discard here: on thin storage a discard before the
# zeros frees the old blocks instead of overwriting them (see --reclaim).
wipe_device() {
  local dev="$1" rc=0
  log "=== zeroize + verify: $dev (${DEV_MODEL[$dev]}, serial ${DEV_SERIAL[$dev]}, ${DEV_SIZE[$dev]} bytes) ==="
  hpa_check "$dev" || rc=1
  step "$dev" wipefs wipefs -a -f "$dev" || true
  zeroize_device "$dev" || rc=1
  [[ $IN_PLACE -eq 1 && -n ${WHOLE_FS[$dev]:-} ]] && { rezero_sigs "$dev" || rc=1; }
  [[ $IN_PLACE -eq 1 ]] && { fence_disk "$dev" || rc=1; }
  if [[ $VERIFY_SAMPLE -gt 0 ]]; then
    verify_sample "$dev" "$VERIFY_SAMPLE" || rc=1
  else
    verify_full "$dev" || rc=1
  fi
  verify_extents "$dev" || rc=1
  return "$rc"
}

wipe_run() {
  local d failed=0 verdict ended receipt sz
  STARTED=${SLAG_STARTED:-$(now)}
  if [[ $DRY_RUN -eq 0 ]]; then
    [[ -n ${SLAG_RAM:-} && ${BASH_SOURCE[0]} == "$SLAG_RAM/"* ]] \
      || die "the wipe stage only runs from its tmpfs copy"
    trap '' HUP
    PATH="$SLAG_RAM/bin:$SLAG_RAM/bb:$PATH"
    ram_shadow_tools "$SLAG_RAM"
    net_load
    log "slag $VERSION wipe running from tmpfs $SLAG_RAM (pid $$); log: $SLAG_LOG"
    resolve_targets
  fi
  [[ -z $PATTERN ]] || pattern_lookup "$PATTERN" || die "--pattern $PATTERN: unknown name"
  for d in "${TARGETS[@]}"; do
    DEV_MODEL[$d]=$(lsfield "$d" MODEL)
    DEV_SERIAL[$d]=$(lsfield "$d" SERIAL)
    sz=$(lsblk -bdno SIZE -- "$d" 2>/dev/null) || sz=
    DEV_SIZE[$d]=$(trim "$sz")
  done

  serve_start
  map_files
  quiesce
  for d in "${TARGETS[@]}"; do collect_sigs "$d"; done
  # Secrets first: every target is struck before any target is erased.
  if [[ $STRIKE -eq 1 ]]; then
    strike_load
    for d in "${TARGETS[@]}"; do strike_device "$d" || STRIKE_FAILED=1; done
  else
    for d in "${TARGETS[@]}"; do DEV_STRIKE[$d]="not run (--no-strike)"; record "$d" strike skipped "--no-strike"; done
  fi
  if [[ $STRIKE_ONLY -eq 1 ]]; then
    for d in "${TARGETS[@]}"; do
      DEV_VERDICT[$d]="STRIKE-ONLY (not wiped)"
      record "$d" wipe skipped "--strike-only: no hardware erase, no zeroize, no verify"
    done
  else
    hw_erase_all
    for d in "${TARGETS[@]}"; do
      if wipe_device "$d"; then
        DEV_VERDICT[$d]=PASS
        [[ $DRY_RUN -eq 1 ]] && DEV_VERDICT[$d]=DRY-RUN
      else
        DEV_VERDICT[$d]=FAIL; failed=1
      fi
    done
  fi
  if [[ $STRIKE_ONLY -eq 1 ]]; then :
  elif [[ $RECLAIM -eq 1 ]]; then
    if [[ $failed -ne 0 ]]; then
      for d in "${TARGETS[@]}"; do record "$d" reclaim skipped "not every target verified; nothing discarded"; done
    else
      for d in "${TARGETS[@]}"; do
        reclaim_device "$d" || { DEV_VERDICT[$d]=FAIL; failed=1; }
      done
    fi
  fi
  if [[ $STRIKE_ONLY -eq 0 ]]; then
    if [[ $IN_PLACE -eq 1 ]]; then
      log "settling ${SETTLE_SECS}s, then re-reading the signature offsets (a live filesystem writes its superblock back)"
      [[ $DRY_RUN -eq 1 ]] || sleep "$SETTLE_SECS"
    fi
    for d in "${TARGETS[@]}"; do
      recheck_sigs "$d" || { DEV_VERDICT[$d]=FAIL; failed=1; }
    done
  fi
  # The pattern only after every target has verified zero (and re-checked).
  if [[ -n $PATTERN && $STRIKE_ONLY -eq 0 ]]; then
    if [[ $failed -ne 0 ]]; then
      for d in "${TARGETS[@]}"; do
        DEV_PATTERN[$d]="not written: not every target verified zero"
        record "$d" pattern skipped "not every target verified zero; no pattern written"
      done
    elif [[ $IN_PLACE -eq 1 ]]; then
      failed=1
      for d in "${TARGETS[@]}"; do
        DEV_PATTERN[$d]="FAILED: filesystems were mounted (in place); the disks are read-only"
        record "$d" pattern failed "in place: the disks were set read-only for the verify"
        DEV_VERDICT[$d]=FAIL
      done
    else
      for d in "${TARGETS[@]}"; do
        pattern_device "$d" || { DEV_VERDICT[$d]=FAIL; failed=1; }
      done
    fi
  fi

  echo >&2
  if [[ $DRY_RUN -eq 1 ]]; then
    verdict="DRY-RUN (nothing was written, erased or read)"
  elif [[ $STRIKE_ONLY -eq 1 ]]; then
    if [[ $STRIKE_FAILED -ne 0 ]]; then failed=1; verdict="FAIL - strike-only: a struck range did not read back zero; the disks are NOT wiped"
    else verdict="STRIKE-ONLY - key signatures and mapped secret files zeroed; the disks are NOT wiped"; fi
  elif [[ $failed -ne 0 ]]; then
    verdict="FAIL - one or more targets did not verify; do not trust this host as wiped"
  elif [[ $QUIESCE_FAILED -ne 0 ]]; then
    failed=1
    verdict="FAIL - swap, a filesystem or an md/dm device on a target stayed active and may write after the verify"
  elif [[ $VERIFY_SAMPLE -gt 0 ]]; then
    verdict="PASS (sampled) - every sampled block read back as zero; unsampled blocks are unproven"
  else
    verdict="PASS - the targeted devices read back as zero, every byte; no filesystem on them remains"
  fi
  if [[ $DRY_RUN -eq 0 && $failed -eq 0 && -n $PATTERN && $STRIKE_ONLY -eq 0 ]]; then
    verdict+="; then the $PATTERN pattern was written and its last pass read back in full (SHA-256 per device): the disks now hold that pass, not zeros"
  fi
  if [[ $DRY_RUN -eq 0 && $failed -eq 0 && $IN_PLACE -eq 1 ]]; then
    verdict+=" (in-place: as of the re-check ${SETTLE_SECS}s after the verify; the running kernel can still write later)"
  fi
  ended=$(now)
  receipt=$(build_receipt "$ended" "$verdict")
  emit_receipt "$receipt"

  if [[ $DRY_RUN -eq 1 ]]; then
    send_receipt
    log "dry run complete; nothing was touched"
    return 0
  fi
  { printf '%s\n' "${verdict%% *}" > "$SLAG_RAM/VERDICT"; } 2>/dev/null || true
  receipt_hash
  serve_publish
  if [[ -n $SEND_ON ]]; then
    send_receipt
    receipt_hash
    serve_publish
  fi
  [[ -z $RECEIPT_SHA ]] || log "receipt SHA-256: $RECEIPT_SHA  $SLAG_RAM/RECEIPT.txt"
  [[ -z $SERVE_TOKEN ]] || log "receipt served at: ${SERVE_URL}RECEIPT.txt (until power-off or ${SERVE_SECS}s)"
  if [[ $failed -ne 0 ]]; then
    err "$verdict"
    exit 1
  fi
  if [[ $STRIKE_ONLY -eq 1 ]]; then
    warn "FINAL: $verdict"
    exit 3
  fi
  good "FINAL: $verdict"
  warn "Still required: delete provider snapshots/backups and complete ROTATION-CHECKLIST.md."
  exit 0
}

# ---- launching and following -----------------------------------------------

launch_detached() {
  local ram="$1" t pid out=/dev/console e
  # LC_ALL=C keeps setlocale from mapping locale-archive off the root fs, and
  # TZ=UTC0 with TZDIR in tmpfs keeps tzset out of /usr/share/zoneinfo: both
  # would otherwise be read on every tool start, after the root disk is erased.
  local args=(wipe) envs=(SLAG_STAGE=run "SLAG_RAM=$ram" "SLAG_LOG=$ram/slag.log" "SLAG_STARTED=$STARTED"
                          LC_ALL=C TZ=UTC0 "TZDIR=$ram/zoneinfo")
  for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
  args+=(--sample "$VERIFY_SAMPLE" --hw-timeout "$HW_TIMEOUT" --yes)
  [[ $RECLAIM -eq 1 ]]       && args+=(--reclaim)
  [[ $STRIKE -eq 0 ]]        && args+=(--no-strike)
  [[ $STRIKE_ONLY -eq 1 ]]   && args+=(--strike-only)
  [[ -n $PATTERN ]]          && args+=(--pattern "$PATTERN")
  [[ $HW_ERASE -eq 0 ]]      && args+=(--no-hw-erase)
  [[ $ALLOW_PARTIAL -eq 1 ]] && args+=(--allow-partial)
  [[ $ALLOW_LOOP -eq 1 ]]    && args+=(--allow-loop)
  [[ $IN_PLACE_WHOLE -eq 1 ]] && args+=(--in-place-whole-disk)
  ram_interp "$ram"
  : > "$ram/slag.log"

  if have systemd-run && [[ -d /run/systemd/system ]]; then
    systemctl is-active --quiet "$UNIT" && die "systemd unit $UNIT is already running; nothing started"
    local sr=(systemd-run --unit "$UNIT" --collect --quiet
              -p StandardInput=null -p StandardOutput=tty -p StandardError=tty
              -p TTYPath=/dev/console)
    for e in "${envs[@]}"; do sr+=("--setenv=$e"); done
    run "${sr[@]}" -- "${INTERP[@]}" "$ram/slag.sh" "${args[@]}" \
      || die "systemd-run failed; nothing has been wiped"
    pid=$(systemctl show -p MainPID --value "$UNIT") || pid=0
    log "wipe running detached as systemd unit $UNIT (pid $pid); it survives this SSH session"
    log "  output: the console (dd progress there); log: tail -f $ram/slag.log"
    log "  status: systemctl status $UNIT   (journalctl -fu $UNIT shows start and exit)"
  else
    [[ -w /dev/console ]] || out=$ram/console.out
    # setsid is not a group leader here, so it execs in place and $! is the wipe.
    env "${envs[@]}" setsid "${INTERP[@]}" "$ram/slag.sh" "${args[@]}" </dev/null >>"$out" 2>&1 &
    pid=$!
    log "wipe running detached via setsid (pid $pid); output: $out; log: tail -f $ram/slag.log"
  fi
  log "receipt will be at $ram/RECEIPT.txt"
  log "following the wipe log (Ctrl-C stops following; the wipe keeps running)"
  export SLAG_STAGE=follow SLAG_RAM="$ram" SLAG_FOLLOW_LOG="$ram/slag.log" SLAG_FOLLOW_PID="${pid:-0}"
  export SLAG_LOG="" LC_ALL=C TZ=UTC0 TZDIR="$ram/zoneinfo"
  exec "${INTERP[@]}" "$ram/slag.sh" wipe
}

# Progress of a long dd, read from the offset of its device fd, so the SSH
# follower sees more than the log (dd's status=progress goes to the console).
dd_progress() {
  local p a args dev fd cnt pos key val
  for p in /proc/[0-9]*; do
    { mapfile -d '' -t args < "$p/cmdline"; } 2>/dev/null || continue
    [[ ${#args[@]} -gt 0 ]] || continue
    [[ ${args[0]##*/} == dd || " ${args[*]} " == *" $RAM_DIR/bin/dd "* ]] || continue
    dev="" fd="" cnt=0
    for a in "${args[@]}"; do
      case "$a" in
        of=/dev/null) ;;
        of=/dev/*) dev=${a#of=}; fd=1 ;;
        if=/dev/zero|if=/dev/urandom) ;;
        if=/dev/*) [[ -n $fd ]] || { dev=${a#if=}; fd=0; } ;;
        count=*) cnt=${a#count=} ;;
      esac
    done
    if [[ -z $fd || ! $cnt =~ ^[0-9]+$ ]] || (( cnt < 64 * 1024 * 1024 )); then continue; fi
    pos=""
    { while read -r key val; do [[ $key == pos: ]] && pos=$val; done < "$p/fdinfo/$fd"; } 2>/dev/null || continue
    [[ $pos =~ ^[0-9]+$ ]] || continue
    printf '%s [progress] %s %s: %s.%s of %s.%s GiB (%s%%)\n' "$(printf '%(%H:%M:%S)T' -1)" \
      "$([[ $fd == 1 ]] && echo writing || echo reading)" "$dev" \
      $(( pos / 1073741824 )) $(( pos * 10 / 1073741824 % 10 )) \
      $(( cnt / 1073741824 )) $(( cnt * 10 / 1073741824 % 10 )) $(( pos * 100 / cnt ))
  done
  return 0
}

wipe_follow() {
  local ram="$SLAG_RAM" pid="${SLAG_FOLLOW_PID:-0}" logf="$SLAG_FOLLOW_LOG" line="" buf="" v="" tick=0
  PATH="$ram/bin:$ram/bb:$PATH"
  ram_shadow_tools "$ram"
  trap 'echo >&2; warn "stopped following; the wipe keeps running. Follow again: tail -f $logf"; exit 130' INT
  exec 3< "$logf" || die "cannot open $logf"
  while :; do
    while IFS= read -r line <&3; do printf '%s\n' "$buf$line"; buf=""; done
    buf+=$line
    if [[ $pid -le 0 ]] || ! kill -0 "$pid" 2>/dev/null; then break; fi
    tick=$(( tick + 1 ))
    (( tick % 15 == 0 )) && dd_progress
    sleep 1
  done
  while IFS= read -r line <&3; do printf '%s\n' "$buf$line"; buf=""; done
  [[ -z $buf$line ]] || printf '%s\n' "$buf$line"
  { read -r v < "$ram/VERDICT"; } 2>/dev/null || v=""
  case "$v" in
    PASS) exit 0 ;;
    FAIL) exit 1 ;;
    STRIKE-ONLY) exit 3 ;;
    *)    err "the wipe ended without a verdict; check the console and: journalctl -u $UNIT"; exit 2 ;;
  esac
}

# In place, a filesystem mounted directly on a whole target disk is refused
# unless --in-place-whole-disk: the real host this was learned on failed its
# verify at byte offset 1036 (the ext4 superblock, written back during
# zeroize). Before the confirmation, like every other refusal.
whole_disk_check() {
  local wd
  wd=$(whole_disk_mounts)
  [[ -n $wd ]] || return 0
  if [[ $IN_PLACE_WHOLE -eq 0 ]]; then
    err "a filesystem is mounted directly on a whole target disk (no partition table): ${wd//$'\n'/, }"
    err "An in-place wipe cannot protect such a disk. Zeroize must write the disk itself, so it"
    err "cannot be set read-only first, and the running kernel can write the filesystem's superblock"
    err "(from byte 1024) back onto it after zeroize has zeroed that block. The verify or the"
    err "re-check then fails (on a real host: nonzero data at byte offset 1036)."
    err "Wipe it from the provider's rescue system, where nothing is mounted from the target."
    die "refused; to wipe in place anyway, pass --in-place-whole-disk"
  fi
  warn "--in-place-whole-disk: a filesystem is mounted directly on a whole target disk: ${wd//$'\n'/, }"
  warn "it stays writable through zeroize; its signature blocks are zeroed again just before it is"
  warn "set read-only, but the running kernel may still write there. A rescue system avoids this."
  return 0
}

phase_wipe() {
  # The stages exit on their own; the returns are only a backstop.
  # shellcheck disable=SC2317
  case "${SLAG_STAGE:-}" in
    run)      wipe_run; return ;;
    follow)   wipe_follow; return ;;
    selftest) selftest_inner; return ;;
    "")       ;;
    *)        die "unknown SLAG_STAGE=$SLAG_STAGE" ;;
  esac
  export TZ=UTC
  [[ ${#TARGETS[@]} -gt 0 ]] || die "wipe needs at least one --target /dev/DISK"
  resolve_targets
  [[ $DRY_RUN -eq 1 || $EUID -eq 0 ]] || die "wipe must run as root (or with --dry-run)"
  if [[ $DRY_RUN -eq 0 && $ASSUME_YES -eq 0 && ! -t 0 ]]; then
    die "stdin is not a terminal, so the typed confirmation cannot be asked; pass --yes to run unattended"
  fi
  [[ -z $PATTERN ]] || pattern_check
  net_check
  whole_disk_check
  [[ -z $STRIKE_FILE ]] || strike_check_file "$STRIKE_FILE"

  print_proof_caveats
  show_targets
  net_announce
  confirm_wipe
  STARTED=$(now)

  if [[ $DRY_RUN -eq 1 ]]; then
    log "(dry-run) would mount a fresh tmpfs at /run/slag.<UTC stamp>, copy bash, this script and"
    log "(dry-run)   ${WIPE_TOOLS[*]} (with shared libraries) into it, self-test them,"
    log "(dry-run)   launch the wipe detached as systemd unit $UNIT with --yes, and follow its log."
    log "(dry-run) the detached run would do the following:"
    wipe_run
    return 0
  fi

  local ram; ram=/run/slag.$(printf '%(%Y%m%dT%H%M%SZ)T' -1)
  log "re-exec from tmpfs: mounting $ram"
  mkdir -p "$ram" || die "cannot create $ram"
  mount -t tmpfs -o size=512m,mode=0700 slag "$ram" || die "cannot mount a tmpfs at $ram"
  stage_tools "$ram"
  stage_inventory "$ram"
  stage_strike "$ram"
  stage_net "$ram"
  selftest_tools "$ram" || die "tmpfs self-test FAILED; no disk has been touched"
  launch_detached "$ram"
}

# ===========================================================================

case "$PHASE" in
  inventory) phase_inventory ;;
  checklist) phase_checklist ;;
  wipe)      phase_wipe ;;
  selftest)  phase_selftest ;;
  patterns)  phase_patterns ;;
  all)
    phase_inventory
    phase_checklist
    warn "inventory + checklist done. Wipe is NOT auto-chained."
    warn "Pull $OUTDIR.tar.gz off the host, finish rotation, then run:"
    { printf '  %s wipe' "$PROG"; for _t in "${TARGETS[@]}"; do printf ' --target %s' "$_t"; done; printf '\n'; } >&2
    ;;
  *) usage; die "unknown phase: $PHASE" ;;
esac
