#!/usr/bin/env bash
#
# no-secrets - Agora - unit 001, wave 3, task T-303.
#
# Fails if the working tree contains anything shaped like a real credential.
#
# Contract (shared by every invariant in tests/bin/):
#   scope:    the exact set of files and directories inspected
#   scanned:  N files, and N must be > 0 or this script fails
#   findings: M, each printed as  path:line  reason
#   exit 0 if and only if M == 0
#
# Design notes, each one paid for with a real failure of this project:
#
#   I-007  An `exit 0` without counts proves nothing: a grep with no files exits
#          0 exactly like a clean tree. An empty scope, a missing tool or an
#          unreadable file is therefore a FINDING here, never a skip.
#
#   I-019  Shell globs do not match dotfiles (`ls *.example` returned 0 with
#          `.gitattributes.example` sitting right there). The scope comes from
#          `find`. It also never comes from `git grep`, which only sees tracked
#          files - a freshly written offender that was never `git add`ed would
#          pass invisibly.
#
#   I-018  A pattern that cannot tell a DEFINITION from a MENTION is
#          mis-specified. This repository deliberately contains prose about
#          hunting for secrets (.claude/skills/exportar-config-limpia/SKILL.md
#          and AGENTS.md both spell out `api[_-]?key|secret|token|passwd|password`).
#          That prose must not turn this invariant red. Level 2 below therefore
#          matches an ASSIGNMENT carrying a value of real length, not the bare
#          keyword.
#
#   No silent exclusions. Every exclusion and every suppression this script
#   applies is PRINTED and COUNTED on every run, because a suppression nobody
#   sees is a suppression that grows. When a clean tree produces a false
#   positive the rule is to tighten the PATTERN; a file is never added to an
#   ignore list. The one path-based exclusion is this script's own path, which
#   necessarily contains the shapes it hunts for.
#
#
# House rules for tests/bin/ (T-321(c)): `wc -l` never `grep -c` for a compared
# value; numeric guards defaulted ONLY where zero means FAIL; grep rc >= 2 is an
# error, never "no match"; never `-F` with `-i`. The CANONICAL copy of the block,
# with the reasoning and the I-031 asymmetry spelled out, lives in the header of
# tests/bin/no-ci-allow-dev. Read it before adding a counter here.
#
# Usage: tests/bin/no-secrets   (run from anywhere; it cd's to the repo root)

set -u
# No `set -e`: a finding must be recorded and counted, not abort the run.

# ---------------------------------------------------------------- repo root --
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
cd "$REPO_ROOT" || { echo "FATAL: cannot cd to repo root $REPO_ROOT"; exit 1; }

# This script's own path, relative to the repo root. The single path exclusion.
SELF_REL="./${SCRIPT_DIR#"$REPO_ROOT"/}/$(basename -- "$0")"

C_OK=""; C_BAD=""; C_OFF=""; C_DIM=""
if [ -t 1 ]; then
  C_OK=$'\033[32m'; C_BAD=$'\033[31m'; C_OFF=$'\033[0m'; C_DIM=$'\033[2m'
fi

FINDINGS=0
SUPPRESSED=0

# finding <path> <line> <reason>
finding() {
  FINDINGS=$((FINDINGS + 1))
  printf '  %s%s:%s%s  %s\n' "$C_BAD" "${1#./}" "$2" "$C_OFF" "$3"
}

# suppressed <path> <line> <reason> - printed and counted, but not a finding.
suppressed() {
  SUPPRESSED=$((SUPPRESSED + 1))
  printf '  %s%s:%s  suppressed: %s%s\n' "$C_DIM" "${1#./}" "$2" "$3" "$C_OFF"
}

note() { printf '  %s%s%s\n' "$C_DIM" "$1" "$C_OFF"; }

# ------------------------------------------------------- grep's third state --
# T-316 / I-027. `grep` has THREE exit states, not two: 0 = matched, 1 = no
# match, >= 2 = grep itself FAILED (invalid pattern, unreadable file, crash).
# Every grep in this script used to discard stderr and infer "no match" from "no
# stdout", so a grep that could not run read exactly like a clean tree - a
# scanner that cannot scan reporting that everything is fine.
#
# A grep error is therefore FATAL here: not a warning, and never a finding. A
# finding says "the tree contains a secret"; this says "the tree was not
# searched", which is a different statement and must not be counted as one.
# rc 1 keeps its meaning everywhere below (no match / binary file / not a
# placeholder) - only rc >= 2 aborts.
fatal() { # <reason>
  printf '\nFATAL: %s\n' "$1"
  printf 'The scan did not complete: this run has NO result, which is not a clean result.\n'
  printf '(T-316 / I-027)\n'
  exit 1
}

grep_failed() { # <rc> <pattern> <subject>
  fatal "$(printf 'grep failed (exit %s) on %s while matching: %s' "$1" "$3" "$2")"
}

# ------------------------------------------------------------ scope pruning --
# Directories excluded from the scan: VCS metadata and build artefacts only.
# Nothing under them is ever authored by this repository.
PRUNED='./.git ./vendor ./node_modules ./web'

scan_files() {
  find . \
    -path ./.git -prune -o \
    -path ./vendor -prune -o \
    -path ./node_modules -prune -o \
    -path ./web -prune -o \
    -type f -print 2>/dev/null | LC_ALL=C sort
}

# ------------------------------------------------ tracked / untracked split --
# T-1915. WHY THE SCOPE TOTAL IS NOT A DENOMINATOR, measured rather than
# argued. This scan walks the FILESYSTEM on purpose - I-019 above says why, and
# the reason is that an unlicensed or leaked file is at its most dangerous in
# the minutes after it is written and before it is committed. But the
# filesystem is a property of the MACHINE and not of the commit. On 2026-09-21
# this checkout carried .claude/settings.local.json, five .cspell-cache/ files
# and four tests/bin/__pycache__/ entries that no clone of the same commit has,
# so this invariant reported 464 text files and 88 binaries opened here, and
# 458 and 84 in CI, for one commit.
#
# Note that the invariants reading the INDEX - the packaged half of level 3,
# tests/bin/executable-bit - agree across both environments and always did.
# That asymmetry is the whole finding.
#
# THE FIX IS NOT TO SCOPE THE WALK TO `git ls-files`: that would delete the one
# thing this scope exists for. It is to PARTITION the same walk. `git-tracked`
# is identical in every clone of a commit; `untracked` is the local difference,
# and it is NAMED rather than merely counted.
#
# THE TWO LABELS CANNOT BE NESTED: the gate runner extracts a figure with an
# ERE and takes the LAST match, so a label that is a SUBSTRING of the other -
# `tracked:` inside `untracked:` - would silently hand it the wrong number.
# This script already carries one scar of exactly that shape, recorded on the
# summary line below: "binaries opened", never "binaries scanned".
#
# A failed `git ls-files` is FATAL, never "0 tracked". A broken split reports
# the whole tree as untracked and the tracked denominator as zero, which is a
# figure that looks like a measurement and is the absence of one (I-028).
split_tracked() { # <path to the scanned list> <dir to write the three sets in>
  _list=$1; _dir=$2
  git -c core.quotePath=false ls-files > "$_dir/tracked-raw" 2>/dev/null \
    || fatal 'git ls-files failed - the tracked/untracked split did not happen, and an unsplit scan cannot be reconciled with any other run of this invariant.'
  LC_ALL=C sort -o "$_dir/tracked-raw" "$_dir/tracked-raw"
  sed 's|^\./||' "$_list" | LC_ALL=C sort > "$_dir/scope-norm"
  LC_ALL=C comm -12 "$_dir/scope-norm" "$_dir/tracked-raw" > "$_dir/scope-tracked"
  LC_ALL=C comm -23 "$_dir/scope-norm" "$_dir/tracked-raw" > "$_dir/scope-untracked"
}

# is_text <file> - true for text, false for binary.
# A zero-byte file has no lines, so `grep -Iq ''` would call it binary; it is
# text, and the `-s` guard says so. `-I` is what makes grep treat a binary as
# non-matching, and that is the whole test.
# rc 1 is the real answer "binary" and is left alone; rc >= 2 used to be read as
# "binary" too, which quietly removed an unscannable file from the scan.
is_text() {
  [ -s "$1" ] || return 0
  LC_ALL=C grep -Iq '' "$1" 2>/dev/null
  _rc=$?
  [ "$_rc" -ge 2 ] && grep_failed "$_rc" "<empty pattern: the text/binary test>" "$1"
  [ "$_rc" -eq 0 ]
}

# CONTAINER_EXT - the file types that can CARRY EXIF or XMP, named by extension.
#
# WHY THIS EXISTS, and it is a hole this script opened in itself. Level 3 used to
# select its files with `is_text()` alone, i.e. with `grep -I` - and `grep -I`
# calls a file binary only when it finds a NUL byte. A PDF has no NUL near its
# head, so grep classifies it as TEXT and the metadata sweep never opens it.
# Measured 2026-08-26, when 39 binaries landed in content/: the sweep opened
# **2 of 39**. The other 37 - 34 PDFs and 3 CSVs - were invisible to level 3,
# while levels 1 and 2 look for credential shapes and not for XMP packets.
#
# A PDF carrying an XMP packet is the ordinary case, not the exotic one: every
# tool that writes a PDF writes one, and it routinely names the author and the
# software. So the file the package is most likely to ship was the file this
# invariant was structurally blind to.
#
# EXTENSIONS RATHER THAN MAGIC BYTES, deliberately. Reading signatures needs a
# tool `tests/bin/doctor` does not certify on this host, and the toolchain floor
# is a certified surface (T-803). An extension list is cheap, explicit, and
# PRINTED on every run, so a reader sees the scope instead of inferring it.
#
# WHAT IT CANNOT DO, stated rather than implied: a container with no extension,
# or with a lying one, is not selected here. That gap is covered from the other
# side - `tests/bin/media-licence` requires every non-YAML file under content/ to
# be declared by path in the manifest, so a rogue file is a finding there.
#
# The set is the union: `grep -I says binary` OR `the extension names a
# container`. Both counts are printed, because a union whose halves are not
# visible is a number nobody can check.
CONTAINER_EXT='pdf jpg jpeg png gif webp tif tiff heic avif woff woff2 svg csv'

is_container_ext() {
  _base=${1##*/}
  case "$_base" in
    *.*) ;;
    *) return 1 ;;
  esac
  _ext=$(printf '%s' "${_base##*.}" | tr '[:upper:]' '[:lower:]')
  for _c in $CONTAINER_EXT; do
    [ "$_ext" = "$_c" ] && return 0
  done
  return 1
}

# True when level 3 should open the file at all.
sweep_me() {
  is_text "$1" || return 0
  is_container_ext "$1"
}

# ------------------------------------------------------------ the patterns ---
# Level 1: high-confidence VALUE shapes. These are issued credentials; a match
# is a finding.
L1_LABELS=(
  'OpenAI-style secret key (sk-...)'
  'GitHub token (ghp_/gho_/ghu_/ghs_/ghr_)'
  'GitHub fine-grained PAT (github_pat_...)'
  'AWS access key id (AKIA...)'
  'Google API key (AIza...)'
  'PEM private key block'
  'Slack token (xox[baprs]-)'
  'JSON Web Token (eyJ...)'
  'connection string with embedded credentials'
)
# NOTE: every grep below passes its pattern with `-e`. Without it, the PEM rule
# `-----BEGIN ... PRIVATE KEY-----` is swallowed by grep as a bundle of options
# and silently matches nothing - a private key in the tree would have produced a
# green run. Verified 2026-08-21 by injecting one.
L1_PATTERNS=(
  'sk-[A-Za-z0-9]{20,}'
  'gh[pousr]_[A-Za-z0-9_]{20,}'
  'github_pat_[A-Za-z0-9_]{20,}'
  'AKIA[0-9A-Z]{16}'
  'AIza[0-9A-Za-z_-]{35}'
  '-----BEGIN [A-Z ]*PRIVATE KEY-----'
  'xox[baprs]-'
  'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.'
  '[a-z][a-z0-9+.-]*://[^/[:space:]:@]+:[^/[:space:]@]+@'
)
# Index of the connection-string pattern; the only level 1 shape that carries a
# human-chosen value rather than an issued one, and therefore the only one with
# a length rule (see MIN_CREDENTIAL_LEN below).
L1_DSN_INDEX=8

# Minimum length for a value to be treated as a real credential. Level 2 already
# uses 8 for the same reason; the connection-string rule reuses it rather than
# inventing a second threshold.
MIN_CREDENTIAL_LEN=8

# Level 2: ASSIGNMENT shapes. The keyword alone is prose; the keyword followed
# by a separator and a value of at least MIN_CREDENTIAL_LEN non-quote,
# non-space characters is a credential being set. This is the discriminator
# that keeps the project's own documentation about secrets out of the findings.
L2_PATTERN="(api[_-]?key|secret|token|passwd|password|client[_-]?secret)[[:space:]]*[:=][[:space:]]*['\"]?[^[:space:]'\"]{8,}"

# Values that are obviously not credentials. A level 2 match whose value is one
# of these is suppressed - printed and counted, never a finding.
#
# Honest caveat: four of the placeholder forms named in the specification -
# `null`, `~`, `''` and `""` - can never reach this stage, because L2_PATTERN
# already requires a value of 8+ characters that are neither quotes nor spaces.
# They are listed anyway so the intent stays readable, but they are unreachable.
PLACEHOLDER_PATTERN='^(null|~|change_?me|todo)$|change_?me|placeholder|example|todo|x{3,}|\$\{[a-z_][a-z0-9_]*\}|<[^>]*>'

# ---------------------------------------------------------------- preflight --
# A missing tool cannot be a reason to report a clean tree.
for tool in find grep sed sort wc comm git; do
  command -v "$tool" >/dev/null 2>&1 || \
    finding '<preflight>' '0' "required tool not available: $tool"
done

printf '=========================================================================================================\n'
printf 'no-secrets - Agora invariant T-303\n'
printf 'repo: %s\n' "$REPO_ROOT"
printf 'date: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '=========================================================================================================\n'

# -------------------------------------------------------------------- scope --
TMPDIR_RUN=$(mktemp -d 2>/dev/null) || { echo "FATAL: cannot create a temp dir"; exit 1; }
trap 'rm -rf "$TMPDIR_RUN"' EXIT
ALL_LIST="$TMPDIR_RUN/all"
scan_files > "$ALL_LIST"
IN_SCOPE=$(wc -l < "$ALL_LIST" | tr -d ' ')

printf '\nscope:    working tree of %s\n' "$REPO_ROOT"
printf '          pruned: %s\n' "$PRUNED"

# T-1915: the same walk, partitioned, so this run and the runner's run of the
# same commit print a figure a reader can put side by side. See split_tracked().
split_tracked "$ALL_LIST" "$TMPDIR_RUN"
TRACKED=$(wc -l < "$TMPDIR_RUN/scope-tracked" | tr -d ' ')
UNTRACKED=$(wc -l < "$TMPDIR_RUN/scope-untracked" | tr -d ' ')
printf 'git-tracked: %s files - untracked: %s files\n' "$TRACKED" "$UNTRACKED"
# Zero means FAIL here, the safe direction of I-031: every clone of this
# repository has hundreds of tracked files, so 0 means the split broke.
if [ "${TRACKED:-0}" -eq 0 ]; then
  finding '<scope>' '0' \
    'the tracked/untracked split reported 0 tracked files - the split broke, and neither of its figures is a measurement'
fi
# The partition must exhaust the walk. If `comm` ever disagrees with `find` -
# a collation mismatch, a path git quotes and find does not - both halves would
# still print as plausible numbers, and only this line would notice.
if [ "$((TRACKED + UNTRACKED))" -ne "${IN_SCOPE:-0}" ]; then
  finding '<scope>' '0' \
    "$(printf 'the split does not exhaust the walk: %s tracked + %s untracked != %s in scope' "$TRACKED" "$UNTRACKED" "$IN_SCOPE")"
fi
if [ "${UNTRACKED:-0}" -gt 0 ]; then
  _shown=0
  while IFS= read -r u; do
    [ -n "$u" ] || continue
    _shown=$((_shown + 1))
    [ "$_shown" -le 25 ] && note "untracked here, in no clone of this commit: $u"
  done < "$TMPDIR_RUN/scope-untracked"
  [ "$UNTRACKED" -gt 25 ] && note "... and $((UNTRACKED - 25)) more; the count above is exact"
fi

# The sensitive directories are reported whether they exist or not. Without this
# line it is possible to hand the invariant a tree it never walked into and
# still read a green result.
for d in config content; do
  if [ -d "$d" ]; then
    printf '          %-9s present (%s file(s))\n' "$d/:" \
      "$(find "$d" -type f 2>/dev/null | wc -l | tr -d ' ')"
  else
    printf '          %-9s absent (0 files)\n' "$d/:"
  fi
done

# --------------------------------------------------------------- exclusions --
# Printed on every run. Exactly one, by exact path.
EXCLUDED=0
grep -qxF "$SELF_REL" "$ALL_LIST" 2>/dev/null
SELF_RC=$?
# rc 1 = this script is not inside the scanned tree, a legitimate branch;
# rc >= 2 would print "0 files excluded" for a lookup that never happened.
[ "$SELF_RC" -ge 2 ] && grep_failed "$SELF_RC" "$SELF_REL" "$ALL_LIST"
if [ "$SELF_RC" -eq 0 ]; then
  EXCLUDED=1
  printf '\nexclusions: 1 file excluded: %s (self)\n' "${SELF_REL#./}"
else
  printf '\nexclusions: 0 files excluded (this script is not inside the scanned tree)\n'
fi

# ------------------------------------------------------ text / binary split --
TEXT_LIST="$TMPDIR_RUN/text"
: > "$TEXT_LIST"
BINARY=0
UNREADABLE=0
BINARY_NAMES=""
BIN_LIST_RAW="$TMPDIR_RUN/binaries"
: > "$BIN_LIST_RAW"
while IFS= read -r f; do
  [ -n "$f" ] || continue
  [ "$f" = "$SELF_REL" ] && continue
  if [ ! -r "$f" ]; then
    UNREADABLE=$((UNREADABLE + 1))
    finding "$f" '0' 'file is not readable - it could not be scanned'
    continue
  fi
  if is_text "$f"; then
    printf '%s\n' "$f" >> "$TEXT_LIST"
    # A container `grep -I` mistook for text still belongs to level 3. It stays
    # in TEXT_LIST as well - levels 1 and 2 scanning a PDF for credential shapes
    # costs nothing and can only find more - but it is ALSO queued for the
    # metadata sweep, which is the half that was missing. See CONTAINER_EXT.
    is_container_ext "$f" && printf '%s
' "${f#./}" >> "$BIN_LIST_RAW"
  else
    BINARY=$((BINARY + 1))
    BINARY_NAMES="$BINARY_NAMES ${f#./}"
    # T-906: one path per line, so level 3 below never has to split a
    # space-separated string. BINARY_NAMES stays because the summary line above
    # prints it, and a path containing a space would be mangled there too - but
    # that is a display defect, whereas splitting the SCOPE on spaces would be a
    # file silently dropped from a scan.
    printf '%s\n' "${f#./}" >> "$BIN_LIST_RAW"
  fi
done < "$ALL_LIST"
SCANNED=$(wc -l < "$TEXT_LIST" | tr -d ' ')

printf '\nfiles in scope:   %s\n' "$IN_SCOPE"
printf '  excluded:       %s (self)\n' "$EXCLUDED"
printf '  binary:         %s%s\n' "$BINARY" "${BINARY_NAMES:+ -$BINARY_NAMES}"
printf '  unreadable:     %s\n' "$UNREADABLE"
printf 'scanned:  %s files\n' "$SCANNED"

# An empty scope is a failure, never a pass. This is the whole point of I-007.
# T-321(b): defaulted because zero means FAIL here - the safe direction of
# I-031. Do not copy the `:-0` to a guard where zero means PASS.
if [ "${IN_SCOPE:-0}" -eq 0 ]; then
  finding '<scope>' '0' 'the scan found 0 files in scope - the result proves nothing'
fi
if [ "${SCANNED:-0}" -eq 0 ] && [ "${IN_SCOPE:-0}" -gt 0 ]; then
  finding '<scope>' '0' 'the scan matched 0 text files - the result proves nothing'
fi

# ------------------------------------------------------------------ level 1 --
printf '\nlevel 1 - high-confidence credential shapes\n'
L1_REPORTED=0
i=0
while [ "$i" -lt "${#L1_PATTERNS[@]}" ]; do
  _pat=${L1_PATTERNS[$i]}
  _lab=${L1_LABELS[$i]}
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    # The grep runs HERE, not inside the `<<<` below: a command substitution is
    # a subshell, and grep's exit status would not survive it.
    _hits=$(LC_ALL=C grep -noIE -e "$_pat" "$f" 2>/dev/null)
    _grc=$?
    [ "$_grc" -ge 2 ] && grep_failed "$_grc" "$_pat" "$f"
    while IFS= read -r rec; do
      [ -n "$rec" ] || continue
      _line=${rec%%:*}
      _match=${rec#*:}
      # The connection-string shape is the only level 1 rule whose value is
      # chosen by a human rather than issued by a provider, so it is the only
      # one that can legitimately carry a throwaway pair. Suppression requires
      # BOTH that the user equals the password AND that it is shorter than
      # MIN_CREDENTIAL_LEN: a real credential is not its own username, so a
      # short-but-genuine password is still reported. Suppressed by VALUE -
      # visibly, and counted - never by file path.
      # Known instance: .tugboat/config.yml hard-codes the preview container's
      # mysql://tugboat:tugboat pair. That file is inherited from the starter
      # kit and is `export-ignore`d, so it never reaches a published release.
      if [ "$i" -eq "$L1_DSN_INDEX" ]; then
        _cred=${_match#*://}
        _cred=${_cred%@*}
        _pw=${_cred#*:}
        _user=${_cred%%:*}
        if [ "$_user" = "$_pw" ] && [ "${#_pw}" -lt "$MIN_CREDENTIAL_LEN" ]; then
          suppressed "$f" "$_line" \
            "connection string whose user equals its password and is shorter than $MIN_CREDENTIAL_LEN chars: $_match"
          L1_REPORTED=$((L1_REPORTED + 1))
          continue
        fi
      fi
      # The value itself is never echoed for a finding: this output reaches CI logs.
      finding "$f" "$_line" "level 1: $_lab (value not echoed)"
      L1_REPORTED=$((L1_REPORTED + 1))
    done <<< "$_hits"
  done < "$TEXT_LIST"
  i=$((i + 1))
done
[ "$L1_REPORTED" -eq 0 ] && note 'no matches'

# ------------------------------------------------------------------ level 2 --
printf '\nlevel 2 - assignment shapes (keyword + separator + value of %s+ chars)\n' "$MIN_CREDENTIAL_LEN"
L2_REPORTED=0
while IFS= read -r f; do
  [ -n "$f" ] || continue
  # As in level 1: grep runs in this shell so that its exit status is readable.
  _hits=$(LC_ALL=C grep -noIE -e "$L2_PATTERN" "$f" 2>/dev/null)
  _grc=$?
  [ "$_grc" -ge 2 ] && grep_failed "$_grc" "$L2_PATTERN" "$f"
  while IFS= read -r rec; do
    [ -n "$rec" ] || continue
    _line=${rec%%:*}
    _match=${rec#*:}
    # Strip `keyword <separator> <spaces> <optional quote>` to isolate the value.
    _val=$(printf '%s' "$_match" | sed -E "s/^[^:=]*[:=][[:space:]]*['\"]?//")
    printf '%s' "$_val" | LC_ALL=C grep -qiE -e "$PLACEHOLDER_PATTERN" 2>/dev/null
    _prc=$?
    # rc 1 = not a placeholder, so it IS a finding - a real branch. rc >= 2 was
    # indistinguishable from it, which meant a broken grep decided the verdict.
    [ "$_prc" -ge 2 ] && grep_failed "$_prc" "$PLACEHOLDER_PATTERN" "<the matched value from $f:$_line>"
    if [ "$_prc" -eq 0 ]; then
      suppressed "$f" "$_line" "placeholder value: $_val"
    else
      finding "$f" "$_line" 'level 2: credential assignment (value not echoed)'
    fi
    L2_REPORTED=$((L2_REPORTED + 1))
  done <<< "$_hits"
done < "$TEXT_LIST"
[ "$L2_REPORTED" -eq 0 ] && note 'no matches'

# ------------------------------------------------------------------ level 3 --
# T-906. The binaries the text/binary split above set aside, swept for EXIF and
# XMP metadata. This is not a new invariant bolted on: it is the closing of a
# hole in THIS one. is_text() uses `grep -I`, so every image in the package is
# skipped by design, and EXIF/XMP carry GPS coordinates, camera serial numbers
# and author names - personal data in a file this script was structurally blind
# to. See plan.md 6.3, and tests/bin/no-secrets-binary.py for the marker list
# and for what the sweep cannot prove.
#
# TWO SCOPES, unlike levels 1 and 2, and for a reason specific to binaries: what
# a marketplace reviewer downloads is the tarball, and an image is exactly the
# kind of file that gets committed once and never looked at again. The packaged
# tree is EXTRACTED rather than listed, because this sweep needs the bytes.
printf '\nlevel 3 - binary metadata (EXIF / XMP / C2PA)\n'
note "containers selected by extension: $CONTAINER_EXT"
note 'the set swept is: (grep -I calls it binary) OR (its extension names a container above)'

BIN_LIST="$BIN_LIST_RAW"

# The packaged half. A failure here is FATAL rather than a finding: an archive
# that did not extract is not an archive with no images in it (I-029).
PKG_DIR="$TMPDIR_RUN/pkg"
PKG_BIN_LIST="$TMPDIR_RUN/pkg-binaries"
: > "$PKG_BIN_LIST"
if git rev-parse --verify HEAD >/dev/null 2>&1; then
  mkdir -p "$PKG_DIR" || fatal 'cannot create the extraction directory for the packaged scope.'
  if git archive --format=tar HEAD 2>/dev/null | tar -xf - -C "$PKG_DIR" 2>/dev/null; then
    while IFS= read -r pf; do
      [ -n "$pf" ] || continue
      sweep_me "$PKG_DIR/$pf" && printf '%s\n' "$pf" >> "$PKG_BIN_LIST"
    done <<EOF
$(cd "$PKG_DIR" && find . -type f -print 2>/dev/null | sed 's|^\./||' | LC_ALL=C sort)
EOF
  else
    fatal 'git archive HEAD | tar -x produced nothing - the packaged binaries were never swept, and an unswept scope is not a clean one.'
  fi
else
  fatal 'no git HEAD - the packaged scope for the binary sweep cannot be resolved.'
fi

WORK_BIN=$(wc -l < "$BIN_LIST" | tr -d ' ')
PKG_BIN=$(wc -l < "$PKG_BIN_LIST" | tr -d ' ')
# T-1915: the working half of this sweep is drawn from the filesystem walk, so
# it carries the same machine-dependence the scope split above fixes - and it
# is the half the "binaries opened" total is built from. On 2026-09-21 the four
# tests/bin/__pycache__/*.pyc of this checkout made it 46 here against 42 in
# CI. Partitioned by the same tracked set, so the two runs reconcile.
LC_ALL=C sort "$BIN_LIST" > "$TMPDIR_RUN/bin-sorted"
LC_ALL=C comm -12 "$TMPDIR_RUN/bin-sorted" "$TMPDIR_RUN/tracked-raw" > "$TMPDIR_RUN/bin-tracked"
WORK_BIN_TRACKED=$(wc -l < "$TMPDIR_RUN/bin-tracked" | tr -d ' ')
WORK_BIN_UNTRACKED=$((WORK_BIN - WORK_BIN_TRACKED))
note "working tree: $WORK_BIN binary file(s) - git-tracked $WORK_BIN_TRACKED, untracked $WORK_BIN_UNTRACKED · packaged tree: $PKG_BIN binary file(s)"

# Framed byte stream on stdin, never paths. `mktemp -d` returns an MSYS path
# (/tmp/...) that the native python3 on this Windows checkout cannot open, and
# the failure mode is "unreadable" - a hole in the scan wearing the mask of a
# finding. sbom-check answered the same wall the same way.
BIN_STREAM="$TMPDIR_RUN/bin-stream"
: > "$BIN_STREAM"
bin_stream_add() { # <label> <readable path>
  _bytes=$(wc -c < "$2" 2>/dev/null | tr -d ' ')
  [ -n "$_bytes" ] || fatal "cannot size $2 - an unmeasured file cannot be streamed, and an unstreamed file is an unswept one."
  printf 'FILE %s %s\n' "$_bytes" "$1" >> "$BIN_STREAM"
  cat "$2" >> "$BIN_STREAM" || fatal "cannot read $2"
}
while IFS= read -r b; do
  [ -n "$b" ] || continue
  bin_stream_add "$b" "$b"
done < "$BIN_LIST"
while IFS= read -r b; do
  [ -n "$b" ] || continue
  bin_stream_add "<packaged>/$b" "$PKG_DIR/$b"
done < "$PKG_BIN_LIST"

BIN_HELPER="$SCRIPT_DIR/no-secrets-binary.py"
[ -r "$BIN_HELPER" ] || fatal "$BIN_HELPER is missing - level 3 cannot be evaluated, and unevaluated is not clean."
command -v python3 >/dev/null 2>&1 || fatal 'python3 is not available - level 3 cannot be evaluated.'
python3 -c 'pass' >/dev/null 2>&1 || fatal 'python3 resolves but does not run - on this host that is the Microsoft Store stub (I-026).'

BIN_OUT="$TMPDIR_RUN/bin.out"
python3 "$BIN_HELPER" < "$BIN_STREAM" > "$BIN_OUT" 2>"$TMPDIR_RUN/bin.err"
BIN_RC=$?
if [ "$BIN_RC" -ne 0 ]; then
  printf '%s\n' "$(cat "$TMPDIR_RUN/bin.err")"
  fatal "the binary-metadata sweep exited $BIN_RC - level 3 was not evaluated. A sweep that did not run is not a sweep that found nothing (I-027)."
fi

BIN_OPENED=0
BIN_EXIF=0
BIN_XMP=0
BIN_C2PA=0
BIN_C2PA=0
BIN_MARKERS=0
BIN_RECORDS=0
while IFS=$'\t' read -r kind a b c; do
  [ -n "$kind" ] || continue
  BIN_RECORDS=$((BIN_RECORDS + 1))
  case "$kind" in
    COUNT)
      case "$a" in
        opened)    BIN_OPENED=$b ;;
        with_exif) BIN_EXIF=$b ;;
        with_xmp)  BIN_XMP=$b ;;
        with_c2pa) BIN_C2PA=$b ;;
        with_c2pa) BIN_C2PA=$b ;;
        markers)   BIN_MARKERS=$b ;;
      esac
      ;;
    NOTE)    note "$a" ;;
    FINDING) finding "$a" "$b" "$c" ;;
  esac
done < "$BIN_OUT"

if [ "${BIN_RECORDS:-0}" -eq 0 ]; then
  fatal 'the binary-metadata sweep produced no records at all - not one count, not one note. It did not run (I-028).'
fi
if [ "${BIN_MARKERS:-0}" -eq 0 ]; then
  fatal 'the binary-metadata marker set is empty - level 3 discriminated nothing while reporting zero findings (I-028).'
fi
# Zero means FAIL, the safe direction for a defaulted guard (I-031). This
# repository always packages at least one binary - screenshot.webp is mandatory
# for a site template - so a zero here means the split or the stream broke, not
# that there is nothing to sweep.
if [ "${BIN_OPENED:-0}" -eq 0 ]; then
  finding '<level 3>' '0' 'the binary sweep opened 0 files - this package always ships at least screenshot.webp, so 0 means the text/binary split or the byte stream broke, not that there is nothing to sweep (I-028)'
fi
printf '%s binaries opened - %s carrying EXIF - %s carrying XMP - %s carrying C2PA\n' "$BIN_OPENED" "$BIN_EXIF" "$BIN_XMP" "$BIN_C2PA"

# ----------------------------------------------------------------- summary ---
printf '\n---------------------------------------------------------------------------------------------------------\n'
printf 'scanned: %s files' "$SCANNED"
if [ -d config ]; then
  printf ' - config/: %s files' "$(find config -type f 2>/dev/null | wc -l | tr -d ' ')"
else
  printf ' - config/: absent (0 files)'
fi
if [ -d content ]; then
  printf ' - content/: %s files' "$(find content -type f 2>/dev/null | wc -l | tr -d ' ')"
else
  printf ' - content/: absent (0 files)'
fi
printf ' - suppressed: %s - excluded: %s (self)' "$SUPPRESSED" "$EXCLUDED"
# T-1915. Printed on the summary line, not only in the scope block above, so a
# reader comparing this run with the runner's run of the same commit never has
# to scroll: `git-tracked` is the figure that is identical in both.
printf ' - git-tracked: %s - untracked: %s' "$TRACKED" "$UNTRACKED"
# T-906. Named "binaries opened", never "binaries scanned": gate-a-wave3.sh
# parses this line for `scanned:[[:space:]]*[0-9]+` and takes the LAST match, so
# a second "scanned:" here would silently redirect that gate's denominator from
# the text scan to the binary sweep. A one-word choice, recorded because the
# failure it avoids would have been invisible.
printf ' - binaries opened: %s - EXIF: %s - XMP: %s - C2PA: %s' "$BIN_OPENED" "$BIN_EXIF" "$BIN_XMP" "$BIN_C2PA"
printf ' - findings: %s\n' "$FINDINGS"

if [ "$FINDINGS" -eq 0 ]; then
  printf '%sno-secrets: clean%s\n' "$C_OK" "$C_OFF"
  exit 0
fi
printf '%sno-secrets: %s finding(s)%s\n' "$C_BAD" "$FINDINGS" "$C_OFF"
exit 1
