#!/bin/bash
# 🤖 scanner-selftest: prove, on THIS engine, that clamd never answers OK for
# an archive it could only partly scan.
#
# scanner-selftest.service runs this every time clamd starts, and the TCP
# listener is bound to that unit, so a failure here keeps port 3310 shut. It
# talks to clamd on its unix socket: the port it guards is not open yet.
#
# Why it exists: with ClamAV's packaged limits, a zip member that expands past
# MaxFileSize is trimmed and the trimmed copy is scanned and answered OK, with
# no limit raised. The box is configured so that cannot happen (both limits
# equal, AlertExceedsMax on). This checks that the configuration still does
# what it did when it was chosen, on whatever version is installed today.
#
# Fixtures are sized for a 100 MiB scan limit. Members are written from a temp
# file so their zip headers are ordinary; streamed members get zip64 headers,
# which clamd does not open at all.
set -u
sock=/run/clamav/clamd.ctl

if [ "${SCANNER_SELFTEST_FORCE_FAIL:-}" = 1 ]; then
  echo "scanner-selftest: FAIL (forced, for the acceptance check)"
  exit 1
fi

d=$(mktemp -d /var/tmp/scanner-selftest.XXXXXX) || exit 1
trap 'rm -rf "$d"' EXIT
echo "LocalSocket $sock" > "$d/clamdscan.conf"

python3 - "$d" <<'PY' || { echo "scanner-selftest: FAIL (could not build fixtures)"; exit 1; }
import io, gzip, os, random, sys, tarfile, zipfile
d = sys.argv[1]
fx = os.path.join(d, 'fx')
os.mkdir(fx)
EICAR = rb'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'

def block(seed):
    # 1 MiB: a 16 KiB random block repeated. It compresses about 140 to 1 and
    # differs per seed, so clamd's clean cache cannot wave a member through.
    r = random.Random(seed)
    return bytes(r.getrandbits(8) for _ in range(16384)) * 64

def bigfile(name, mb, seed):
    p = os.path.join(d, name)
    blk = block(seed)
    with open(p, 'wb') as f:
        for _ in range(mb):
            f.write(blk)
    return p

def mkzip(name, build):
    with zipfile.ZipFile(os.path.join(fx, name), 'w', zipfile.ZIP_DEFLATED) as z:
        build(z)

open(os.path.join(fx, '01-clean.txt'), 'wb').write(b'hello world\n')
open(os.path.join(fx, '02-eicar.txt'), 'wb').write(EICAR)
big = bigfile('big.bin', 500, 1)
mid = bigfile('mid.bin', 60, 5)
mkzip('03-zip-one-500.zip', lambda z: z.write(big, 'big.bin'))
with zipfile.ZipFile(os.path.join(d, 'inner.zip'), 'w', zipfile.ZIP_DEFLATED) as z:
    z.write(big, 'big.bin')
mkzip('04-zip-nested-500.zip', lambda z: z.write(os.path.join(d, 'inner.zip'), 'inner.zip'))
def many(z):
    for i in range(30):
        z.write(bigfile('m.bin', 20, 100 + i), 'm%02d.bin' % i)
mkzip('05-zip-30x20.zip', many)
with gzip.open(os.path.join(fx, '06-gzip-500.gz'), 'wb') as g:
    blk = block(2)
    for _ in range(500):
        g.write(blk)
with tarfile.open(os.path.join(fx, '07-targz-5x40.tar.gz'), 'w:gz') as t:
    for i in range(5):
        data = block(200 + i) * 40
        ti = tarfile.TarInfo('t%d.bin' % i)
        ti.size = len(data)
        t.addfile(ti, io.BytesIO(data))
def big_then_eicar(z):
    z.write(big, 'big.bin')
    z.writestr('eicar.com', EICAR)
mkzip('08-zip-500-then-eicar.zip', big_then_eicar)
def mid_then_eicar(z):
    z.write(mid, 'mid.bin')
    z.writestr('eicar.com', EICAR)
mkzip('09-zip-60-then-eicar.zip', mid_then_eicar)
mkzip('10-zip-one-60.zip', lambda z: z.write(mid, 'mid.bin'))
PY

# What each fixture must answer. "limit" is the one that matters: an OK there
# is a truncated scan signed clean.
want() {
  case $1 in
    01 | 10) echo ok ;;
    02 | 09) echo infected ;;
    *) echo limit ;;
  esac
}

# >>> classify
# What a reply says: ok, infected, limit, timeout or error. The signature is
# read as internal/clamd reads it: drop the trailing " FOUND", keep what follows
# the last ": " (clamdscan puts the file path there, not "stream"). A limit is
# Heuristics.Limits.Exceeded itself or a name under it, dot included, so a
# lookalike such as Win.Heuristics.Limits.Exceeded.Fake is malware. MaxScanTime
# is a timeout: it says the engine ran out of time, not that a limit held, so
# it never matches what a fixture wants.
classify() {
  local sig
  case $1 in
    *" FOUND")
      sig=${1% FOUND}
      sig=${sig##*: }
      case $sig in
        Heuristics.Limits.Exceeded.MaxScanTime) echo timeout ;;
        Heuristics.Limits.Exceeded | Heuristics.Limits.Exceeded.*) echo limit ;;
        *) echo infected ;;
      esac
      ;;
    *": OK") echo ok ;;
    *) echo error ;;
  esac
}
# <<< classify

fail=0
for f in "$d"/fx/*; do
  n=$(basename "$f")
  exp=$(want "${n%%-*}")
  reply=$(clamdscan --config-file="$d/clamdscan.conf" --stream --no-summary "$f" 2>&1 | tail -1)
  got=$(classify "$reply")
  if [ "$got" = "$exp" ]; then
    echo "scanner-selftest: pass  $n  ($got)"
  elif [ "$got" = timeout ]; then
    echo "scanner-selftest: FAIL  $n  timeout, not a verdict (the engine is too slow or overloaded): $reply"
    fail=1
  else
    echo "scanner-selftest: FAIL  $n  want $exp, got $got: $reply"
    fail=1
  fi
done

if [ $fail -eq 0 ]; then
  echo "scanner-selftest: PASS"
else
  echo "scanner-selftest: FAIL"
fi
exit $fail
