#!/usr/bin/env bash
# Regression suite for the single-pass pre-flight prescribed by SKILL.md step 2.
# Each test builds a fixture zip, runs the exact awk one-liners from SKILL.md
# against `unzip -Z` output, and asserts the expected gate fires.
#
# Run from anywhere: `bash platform/plugins/admin/skills/unzip-attachment/__tests__/preflight.sh`
# Exits 0 if all assertions pass, non-zero on the first failure.

set -uo pipefail

WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

PASS=0
FAIL=0
fail() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
pass() { echo "PASS: $1"; PASS=$((PASS+1)); }

# --- The four awk gates from SKILL.md step 2 -----------------------------------
# Each takes `unzip -Z $zip` on stdin and exits 0 if the gate FIRES (attack
# detected), non-zero if the zip is clean for that gate.

detect_symlink()   { awk 'NR>2 && $1 ~ /^l/ { found=1; print $0 } END { exit !found }'; }
detect_encrypted() { awk 'NR>2 && $5 ~ /^[TB]/ { found=1; print $0 } END { exit !found }'; }
detect_slip()      { awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { for (i=9; i<=NF; i++) if ($i ~ /^\.\.\//) { found=1; print $i } } END { exit !found }'; }
sum_uncompressed() { awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { s += $4 } END { print s+0 }'; }
extract_names()    { awk 'NR>2 && $1 ~ /^[-lrwxd?]/ { for (i=9; i<=NF; i++) printf "%s%s", $i, (i<NF?" ":"\n") }'; }

LIMIT=$((100 * 1024 * 1024))

# --- (a) Password-protected archive refused ----------------------------------
cd "$WORK"
echo "secret" > a-secret.txt
zip -e -P testpass a-pw.zip a-secret.txt >/dev/null 2>&1
if unzip -Z a-pw.zip 2>/dev/null | detect_encrypted >/dev/null; then
  pass "(a) password — col-5 uppercase first letter detected"
else
  fail "(a) password — col-5 uppercase first letter NOT detected"
fi

# --- (b) Symlink entry refused -----------------------------------------------
cd "$WORK"
ln -s /etc/passwd b-link
echo "ok" > b-ok.txt
zip -y b-sym.zip b-link b-ok.txt >/dev/null 2>&1
if unzip -Z b-sym.zip 2>/dev/null | detect_symlink >/dev/null; then
  pass "(b) symlink — col-1 leading 'l' detected"
else
  fail "(b) symlink — col-1 leading 'l' NOT detected"
fi

# --- (c) Zip-slip entry refused by pre-scan ---------------------------------
# The PRE-scan must detect `../`-prefixed entry names in `unzip -Z` column 9+
# BEFORE extraction runs. macOS unzip silently sanitises these paths during
# extraction (the file lands inside dest with `../` stripped), so the
# post-extraction realpath check is a defense-in-depth backstop for unzip
# versions that don't sanitise — not the primary gate.
# zip(1) refuses `..` paths, so python forges the central-directory entry.
cd "$WORK"
python3 - <<'PY' >/dev/null 2>&1
import zipfile
with zipfile.ZipFile("c-slip.zip", "w") as z:
    z.writestr("../../escapee.txt", "evil payload")
    z.writestr("good.txt", "ok")
PY
if unzip -Z c-slip.zip 2>/dev/null | detect_slip >/dev/null; then
  pass "(c) zip-slip — pre-scan caught '../' in entry name"
else
  fail "(c) zip-slip — pre-scan missed '../' in entry name"
fi

# --- (d) Oversize archive refused (declared > 100 MiB) -----------------------
# Building a 100 MiB+ fixture is expensive on disk and CI. Instead, simulate
# the awk gate against a synthetic `unzip -Z`-shaped fixture whose column-4
# sum exceeds the limit. This tests the *gate logic*, not unzip itself.
cd "$WORK"
cat > d-zfake.txt <<'FAKE'
Archive:  d-big.zip
Zip file size: 999 bytes, number of entries: 2
-rw-r--r--  3.0 unx 60000000 tx stor 26-May-10 21:08 big1.bin
-rw-r--r--  3.0 unx 60000000 tx stor 26-May-10 21:08 big2.bin
2 files, 120000000 bytes uncompressed, 999 bytes compressed:  0.0%
FAKE
SUM=$(sum_uncompressed < d-zfake.txt)
if [ "$SUM" -gt "$LIMIT" ]; then
  pass "(d) oversize — sum=$SUM > limit=$LIMIT detected"
else
  fail "(d) oversize — sum=$SUM did not exceed limit=$LIMIT (gate broken)"
fi

# --- (e) Clean zip = exactly 1 pre-flight + 1 extraction ---------------------
cd "$WORK"
mkdir e-stage && cd e-stage
echo "alpha" > a.txt
mkdir sub && echo "beta" > sub/b.txt
zip -r ../e-clean.zip a.txt sub >/dev/null 2>&1
cd "$WORK"

# Wrap unzip in a tracer that increments a counter file per invocation, scoped
# by the flag combination so we can count pre-flight vs extraction calls.
TRACE_DIR=$(mktemp -d)
mkdir -p "$TRACE_DIR/bin"
cat > "$TRACE_DIR/bin/unzip" <<TRACE
#!/usr/bin/env bash
# Classify the call: -Z* = pre-flight (zipinfo mode), -o* = extraction.
case "\$1" in
  -Z*) echo "Z" >> "$TRACE_DIR/calls" ;;
  -o*) echo "O" >> "$TRACE_DIR/calls" ;;
  -t*) echo "T" >> "$TRACE_DIR/calls" ;;
  *)   echo "?" >> "$TRACE_DIR/calls" ;;
esac
exec /usr/bin/unzip "\$@"
TRACE
chmod +x "$TRACE_DIR/bin/unzip"
: > "$TRACE_DIR/calls"

# Run the prescribed clean-path: 1 `unzip -Z` + 1 `unzip -oq`.
mkdir e-dest
PATH="$TRACE_DIR/bin:/usr/bin:/bin" unzip -Z e-clean.zip >/dev/null 2>&1
PATH="$TRACE_DIR/bin:/usr/bin:/bin" unzip -oq e-clean.zip -d e-dest >/dev/null 2>&1

Z_COUNT=$(grep -c '^Z$' "$TRACE_DIR/calls" || true)
O_COUNT=$(grep -c '^O$' "$TRACE_DIR/calls" || true)
T_COUNT=$(grep -c '^T$' "$TRACE_DIR/calls" || true)

if [ "$Z_COUNT" = "1" ] && [ "$O_COUNT" = "1" ] && [ "$T_COUNT" = "0" ]; then
  pass "(e) clean — exactly 1 pre-flight + 1 extraction (Z=$Z_COUNT O=$O_COUNT T=$T_COUNT)"
else
  fail "(e) clean — wrong invocation count Z=$Z_COUNT O=$O_COUNT T=$T_COUNT"
fi

# --- (f) Filenames with spaces survive name extraction -----------------------
cd "$WORK"
mkdir f-stage && cd f-stage
echo "hello" > "hello world.txt"
zip ../f-space.zip "hello world.txt" >/dev/null 2>&1
cd "$WORK"
NAMES=$(unzip -Z f-space.zip 2>/dev/null | extract_names)
if [ "$NAMES" = "hello world.txt" ]; then
  pass "(f) whitespace name — extracted intact: '$NAMES'"
else
  fail "(f) whitespace name — corrupted: got '$NAMES' want 'hello world.txt'"
fi

# --- Summary -----------------------------------------------------------------
echo
echo "RESULT: $PASS passed, $FAIL failed"
exit "$FAIL"
