#!/usr/bin/env bash # # run-tests.sh — host-side regression tests for the sandbox scripts. # # Runs extract.sh and scan-files.php straight from the checkout (no image # build): needs bash, php-cli, python3 and GNU tar. clamscan is replaced by # a stub on PATH that records its argv and reports fixed "FOUND" hits, so # the scan-files.php plumbing (exclude flags, quarantine, counts) is tested # without ClamAV or signatures. The full image is covered by the docker # commands in CONTRIBUTING.md. # # Run: bash tests/run-tests.sh (exit 0 = all pass) set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FIX="$REPO/tests/fixtures" # Under /tmp: scan-files.php sets a container open_basedir that allows /tmp. WORK="$(mktemp -d -p /tmp cpi-tests.XXXXXX)" trap 'rm -rf "$WORK"' EXIT FAILS=0 pass() { printf ' PASS %s\n' "$1"; } fail() { printf ' FAIL %s\n' "$1"; FAILS=$((FAILS + 1)); } check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; } bash "$REPO/tests/build-fixtures.sh" >/dev/null # Copied under /tmp: scan-symlinks.php opens the tarball under the same open_basedir. cp "$FIX/cpmove-mail.tar.gz" "$WORK/" MAILTAR="$WORK/cpmove-mail.tar.gz" U=cpmove-testuser D=example.com # ---- extract.sh: default (no opt-in) skips the mail tree ------------------- echo "extract.sh without IMPORT_INCLUDE_MAIL" OUT="$WORK/ex-default" LOG="$WORK/ex-default.log" env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-default.actions.json" >"$LOG" 2>&1 check "default: homedir/mail not extracted" "[[ ! -e '$OUT/$U/homedir/mail' ]]" check "default: homedir/etc//passwd still extracted" "[[ -f '$OUT/$U/homedir/etc/$D/passwd' ]]" check "default: log says the mail tree was skipped" "grep -q 'skipping mail tree' '$LOG'" # ---- extract.sh: opt-in includes it ----------------------------------------- echo "extract.sh with IMPORT_INCLUDE_MAIL=true" OUT="$WORK/ex-mail" LOG="$WORK/ex-mail.log" IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-mail.actions.json" >"$LOG" 2>&1 check "opt-in: INBOX message extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/cur/1700000000.M1P1.host,S=40:2,S' ]]" check "opt-in: subfolder message extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/.Archive/cur/1700000001.M2P1.host,S=43:2,' ]]" check "opt-in: subscriptions extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/subscriptions' ]]" check "opt-in: empty Maildir kept with cur/new/tmp" "[[ -d '$OUT/$U/homedir/mail/$D/empty/cur' && -d '$OUT/$U/homedir/mail/$D/empty/new' && -d '$OUT/$U/homedir/mail/$D/empty/tmp' ]]" check "opt-in: DANGEROUS symlink inside the mail tree stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/.evil' && ! -L '$OUT/$U/homedir/mail/$D/alice/.evil' ]]" check "opt-in: the strip is recorded as an action" "grep -q 'homedir/mail/$D/alice/.evil' '$WORK/ex-mail.actions.json'" check "opt-in: relative file symlink (../..../etc/shadow) inside the mail tree stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/.evil-rel' && ! -L '$OUT/$U/homedir/mail/$D/alice/.evil-rel' ]]" check "opt-in: relative folder symlink to another domain's box stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/.Other' && ! -L '$OUT/$U/homedir/mail/$D/alice/.Other' ]]" check "opt-in: no symlink at all left under the mail tree" "[[ -z \$(find '$OUT/$U/homedir/mail' -type l) ]]" check "opt-in: the DANGEROUS in-mail symlink is listed once, not twice" "[[ \$(grep -c 'alice/.evil\"' '$WORK/ex-mail.actions.json') == 1 ]]" check "opt-in: relative symlinks recorded as stripped_mail_link actions" "grep -q 'alice/.evil-rel' '$WORK/ex-mail.actions.json' && grep -q 'alice/.Other' '$WORK/ex-mail.actions.json'" check "opt-in: hard link INSIDE the mail tree (Dovecot copy) kept" "[[ -f '$OUT/$U/homedir/mail/$D/alice/cur/1700000002.M3P1.host,S=41:2,S' && -f '$OUT/$U/homedir/mail/$D/alice/.Archive/cur/1700000002.M3P1.host,S=41:2,S' ]]" check "opt-in: hard link crossing INTO the mail tree (message -> etc/shadow) stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/cur/leak' ]]" check "opt-in: log says the mail tree was included" "grep -q 'including mail tree' '$LOG'" check "opt-in: no 'skipping mail tree' line" "! grep -q 'skipping mail tree' '$LOG'" echo "N2-1: relative symlinks that escape the archive / the homedir (cpmove-relesc)" cp "$FIX/cpmove-relesc.tar.gz" "$WORK/" php "$REPO/scripts/lib/scan-symlinks.php" --tarball "$WORK/cpmove-relesc.tar.gz" --report "$WORK/relesc-scan.json" --username testuser 2>/dev/null relesc_type() { php -r '$r = json_decode(file_get_contents($argv[1]), true); foreach ($r["findings"] as $f) if ($f["archive_path"] === $argv[2]) { echo $f["type"]; exit; } echo "NONE";' "$WORK/relesc-scan.json" "$1"; } check "relesc: sds -> ../../../../etc/passwd is DANGEROUS" "[[ \$(relesc_type '$U/sds') == DANGEROUS ]]" check "relesc: homedir/public_html/dump.sql -> ../../mysql/... (escapes homedir) is DANGEROUS" "[[ \$(relesc_type '$U/homedir/public_html/dump.sql') == DANGEROUS ]]" check "relesc: homedir/www -> public_html (inside homedir) is not a finding" "[[ \$(relesc_type '$U/homedir/www') == NONE ]]" check "relesc: homedir/about-shortcut -> public_html/about.txt is not a finding" "[[ \$(relesc_type '$U/homedir/about-shortcut') == NONE ]]" OUT="$WORK/ex-relesc" env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$WORK/cpmove-relesc.tar.gz" "$OUT" testuser "$WORK/ex-relesc.actions.json" >"$WORK/ex-relesc.log" 2>&1 check "relesc: sds stripped from the extract" "[[ ! -e '$OUT/$U/sds' && ! -L '$OUT/$U/sds' ]]" check "relesc: dump.sql link stripped from the extract" "[[ ! -L '$OUT/$U/homedir/public_html/dump.sql' ]]" check "relesc: homedir/www link kept" "[[ -L '$OUT/$U/homedir/www' ]]" check "relesc: both strips recorded" "grep -q '$U/sds' '$WORK/ex-relesc.actions.json' && grep -q 'public_html/dump.sql' '$WORK/ex-relesc.actions.json'" echo "N2-1 review: symlink chains (cpmove-chain)" cp "$FIX/cpmove-chain.tar.gz" "$WORK/" php "$REPO/scripts/lib/scan-symlinks.php" --tarball "$WORK/cpmove-chain.tar.gz" --report "$WORK/relesc-scan.json" --username testuser 2>/dev/null check "chain: d1/d2/a -> b/../.. (through link b) is DANGEROUS" "[[ \$(relesc_type '$U/d1/d2/a') == DANGEROUS ]]" check "chain: d1/d2/l1 -> l2/../../.. (through link l2) is DANGEROUS" "[[ \$(relesc_type '$U/d1/d2/l1') == DANGEROUS ]]" check "chain: d1/d2/b -> ../.. alone is not a finding" "[[ \$(relesc_type '$U/d1/d2/b') == NONE ]]" OUT="$WORK/ex-chain" env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$WORK/cpmove-chain.tar.gz" "$OUT" testuser "$WORK/ex-chain.actions.json" >"$WORK/ex-chain.log" 2>&1 check "chain: a and l1 absent from the extract" "[[ ! -L '$OUT/$U/d1/d2/a' && ! -L '$OUT/$U/d1/d2/l1' ]]" check "chain: b and l2 kept" "[[ -L '$OUT/$U/d1/d2/b' && -L '$OUT/$U/d1/d2/l2' ]]" echo "N2-1 review: post-extract authority strip-escaping-links.py (links the classifier never saw)" P="$WORK/postx" rm -rf "$P"; mkdir -p "$P/$U/d1/d2" "$P/$U/homedir/public_html/inner" echo x > "$P/$U/homedir/public_html/inner/f" ln -s "../.." "$P/$U/d1/d2/b"; ln -s "b/../.." "$P/$U/d1/d2/a" ln -s "../.." "$P/$U/d1/d2/l2"; ln -s "l2/../../.." "$P/$U/d1/d2/l1" ln -s "/etc/passwd" "$P/$U/homedir/abs-etc" ln -s "nonexistent" "$P/$U/homedir/public_html/x.php.bak.cpanel-import" ln -s "../../../../../../victim/shell.php" "$P/$U/homedir/public_html/y.bak" ln -s "loop2" "$P/$U/homedir/loop1"; ln -s "loop1" "$P/$U/homedir/loop2" ln -s "../.." "$P/$U/top-link" ln -s "inner/f" "$P/$U/homedir/public_html/ok-file" ln -s "public_html" "$P/$U/homedir/www" ln -s "/home/testuser/public_html/inner" "$P/$U/homedir/own-abs" # Review round 2: own-account absolute links are judged re-rooted in the # extract, normalized, and only for the TRUSTED username argument. ln -s "/home/testuser/../../etc/shadow" "$P/$U/homedir/own-dotdot" ln -s "/home/testuser/./public_html/inner" "$P/$U/homedir/own-dot" ln -s "/home/testuser/public_html/missing" "$P/$U/homedir/own-dangling" ln -s "../../.." "$P/$U/homedir/esc"; ln -s "/home/testuser/esc" "$P/$U/homedir/own-via-esc" ln -s "/home/root/x" "$P/$U/homedir/other-user" mkdir -p "$P/cpmove-root/homedir/x"; ln -s "/home/root/x" "$P/cpmove-root/homedir/archive-named-user" echo '{"actions":[{"action":"pre"}]}' > "$WORK/postx.json" n=$(python3 -I "$REPO/scripts/lib/strip-escaping-links.py" "$P" "$WORK/postx.json" testuser 2>/dev/null) for gone in d1/d2/a d1/d2/l1 homedir/abs-etc homedir/public_html/x.php.bak.cpanel-import homedir/public_html/y.bak homedir/loop1 homedir/loop2 top-link \ homedir/own-dotdot homedir/own-dot homedir/own-dangling homedir/esc homedir/own-via-esc homedir/other-user; do check "post-extract: $gone stripped" "[[ ! -L '$P/$U/$gone' ]]" done for kept in d1/d2/b d1/d2/l2 homedir/public_html/ok-file homedir/www homedir/own-abs; do check "post-extract: $kept kept" "[[ -L '$P/$U/$kept' ]]" done check "post-extract: cpmove-root/…/archive-named-user (/home/root/x; 'root' only named by the archive) stripped" "[[ ! -L '$P/cpmove-root/homedir/archive-named-user' ]]" check "post-extract: 15 links stripped (got $n)" "[[ '$n' == 15 ]]" check "post-extract: strips appended as stripped_escaping_symlink actions" "grep -q '\"pre\"' '$WORK/postx.json' && [[ \$(grep -c stripped_escaping_symlink '$WORK/postx.json') == 15 ]]" rm -rf "$P"; mkdir -p "$P/$U/homedir/public_html/inner" ln -s "/home/testuser/public_html/inner" "$P/$U/homedir/own-abs" python3 -I "$REPO/scripts/lib/strip-escaping-links.py" "$P" "$WORK/postx2.json" >/dev/null 2>&1 check "post-extract: with no trusted username, an own-account link is NOT exempt" "[[ ! -L '$P/$U/homedir/own-abs' ]]" rm -rf "$P"; mkdir -p "$P/$U/homedir/public_html/inner" ln -s "/home/testuser/public_html/inner" "$P/$U/homedir/own-abs" python3 -I "$REPO/scripts/lib/strip-escaping-links.py" "$P" "$WORK/postx3.json" 'testuser/../x' >/dev/null 2>&1 check "post-extract: an invalid 'trusted' username exempts nothing" "[[ ! -L '$P/$U/homedir/own-abs' ]]" echo "extract.sh: an archive cannot name its own exempt user (top dir cpmove-root)" AR="$WORK/archuser-src"; rm -rf "$AR"; mkdir -p "$AR/cpmove-root/homedir/x" ln -s "/home/root/x" "$AR/cpmove-root/homedir/self-exempt" tar -C "$AR" -czf "$WORK/archuser.tar.gz" cpmove-root env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$WORK/archuser.tar.gz" "$WORK/ex-archuser" testuser "$WORK/archuser.json" >/dev/null 2>&1 check "archive-named user: /home/root/x link stripped (only the WHP username is trusted)" "[[ -d '$WORK/ex-archuser/cpmove-root/homedir/x' && ! -L '$WORK/ex-archuser/cpmove-root/homedir/self-exempt' ]]" env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$WORK/archuser.tar.gz" "$WORK/ex-archuser2" root "$WORK/archuser2.json" >/dev/null 2>&1 check "control: the same link is kept when WHP itself passes that username" "[[ -L '$WORK/ex-archuser2/cpmove-root/homedir/self-exempt' ]]" echo "scan-symlinks: /home//../../etc is not user-internal" DT="$WORK/dotdot-src"; rm -rf "$DT"; mkdir -p "$DT/$U/homedir" ln -s "/home/testuser/../../etc/shadow" "$DT/$U/homedir/sneaky" tar -C "$DT" -czf "$WORK/dotdot.tar.gz" "$U" php "$REPO/scripts/lib/scan-symlinks.php" --tarball "$WORK/dotdot.tar.gz" --report "$WORK/relesc-scan.json" --username testuser 2>/dev/null check "scan: /home/testuser/../../etc/shadow is DANGEROUS (not BENIGN_USER_INTERNAL)" "[[ \$(relesc_type '$U/homedir/sneaky') == DANGEROUS ]]" echo "hard link crossing OUT of the mail tree (cpmove-mail-xlink)" cp "$FIX/cpmove-mail-xlink.tar.gz" "$WORK/" OUT="$WORK/ex-xlink" IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$WORK/cpmove-mail-xlink.tar.gz" "$OUT" testuser "$WORK/ex-xlink.actions.json" >"$WORK/ex-xlink.log" 2>&1 check "opt-in: public_html/stolen.txt (hard link to a message) stripped" "[[ ! -e '$OUT/$U/homedir/public_html/stolen.txt' ]]" check "opt-in: the linked message itself still extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/.Archive/cur/1700000002.M3P1.host,S=41:2,S' ]]" check "default mode: same archive fails closed (tar cannot link to the excluded mail member)" \ "! env -u IMPORT_INCLUDE_MAIL bash '$REPO/scripts/extract.sh' '$WORK/cpmove-mail-xlink.tar.gz' '$WORK/ex-xlink-default' testuser '$WORK/x.json' >/dev/null 2>&1" echo "parser-differential archives are refused (IMPORT_INCLUDE_MAIL=true)" for bad in dot dslash dotdot abs newline; do cp "$FIX/cpmove-bad-$bad.tar.gz" "$WORK/" OUT="$WORK/ex-bad-$bad" rc=0 IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$WORK/cpmove-bad-$bad.tar.gz" "$OUT" testuser "$WORK/bad-$bad.json" >"$WORK/bad-$bad.log" 2>&1 || rc=$? check "bad-$bad: extract exits non-zero (rc=$rc)" "[[ $rc -ne 0 ]]" check "bad-$bad: refusal logged" "grep -q 'REFUSING archive' '$WORK/bad-$bad.log'" check "bad-$bad: extract dir removed" "[[ ! -e '$OUT' ]]" done echo "failures after extraction starts remove the extract (IMPORT_INCLUDE_MAIL=true)" for bad in unreadable tarfail truncated; do cp "$FIX/cpmove-bad-$bad.tar.gz" "$WORK/" OUT="$WORK/ex-bad-$bad" rc=0 IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$WORK/cpmove-bad-$bad.tar.gz" "$OUT" testuser "$WORK/bad-$bad.json" >"$WORK/bad-$bad.log" 2>&1 || rc=$? check "bad-$bad: extract exits non-zero (rc=$rc)" "[[ $rc -ne 0 ]]" check "bad-$bad: extract dir removed" "[[ ! -e '$OUT' ]]" done check "bad-unreadable: failed in verify (cannot inspect the 0000 dir)" "grep -q 'not readable+searchable\|cannot inspect' '$WORK/bad-unreadable.log'" check "bad-tarfail: tar itself failed (rc logged), verify still ran" "grep -q 'tar failed (rc=' '$WORK/bad-tarfail.log'" check "bad-truncated: refused by the classifier" "grep -q 'REFUSING archive' '$WORK/bad-truncated.log'" echo "classifier refuses when GNU tar lists a different member count" FAKEBIN="$WORK/faketar" mkdir -p "$FAKEBIN" REALTAR="$(command -v tar)" cat > "$FAKEBIN/tar" < "$WORK/cm.excl"; echo '{"actions":[]}' > "$WORK/cm.json" rc=0 PATH="$FAKEBIN:$PATH" python3 -I "$REPO/scripts/lib/mail-tree-links.py" scan "$MAILTAR" "$WORK/cm.excl" "$WORK/cm.json" >/dev/null 2>"$WORK/cm.log" || rc=$? check "count mismatch: scan exits 3 (rc=$rc)" "[[ $rc -eq 3 ]]" check "count mismatch: reason logged" "grep -q 'member count differs' '$WORK/cm.log'" rc=0 python3 -I "$REPO/scripts/lib/mail-tree-links.py" scan "$MAILTAR" "$WORK/cm.excl" "$WORK/cm.json" >/dev/null 2>&1 || rc=$? check "control: real tar, same archive => scan exits 0" "[[ $rc -eq 0 ]]" echo "post-extract authority: mail-tree-links.py verify" V="$WORK/verify" mkvt() { rm -rf "$V"; mkdir -p "$V/$U/homedir/mail/$D/a/cur" "$V/$U/homedir/mail/$D/a/.F/cur" "$V/$U/homedir/public_html" "$V/cpmove-other/homedir/mail/$D/b/cur"; echo m > "$V/$U/homedir/mail/$D/a/cur/1"; } vrc() { local rc=0; python3 -I "$REPO/scripts/lib/mail-tree-links.py" verify "$V" >/dev/null 2>&1 || rc=$?; echo $rc; } mkvt; check "verify: clean tree => 0" "[[ \$(vrc) == 0 ]]" mkvt; ln "$V/$U/homedir/mail/$D/a/cur/1" "$V/$U/homedir/mail/$D/a/.F/cur/1" check "verify: Dovecot hard link inside one mail tree => 0 (kept)" "[[ \$(vrc) == 0 ]]" mkvt; ln -s ../../../../../etc "$V/$U/homedir/mail/$D/a/.evil" check "verify: symlink in the mail tree (survived the pre-filter) => 4" "[[ \$(vrc) == 4 ]]" mkvt; ln "$V/$U/homedir/mail/$D/a/cur/1" "$V/$U/homedir/public_html/out" check "verify: hard link OUT of the mail tree (survived the pre-filter) => 4" "[[ \$(vrc) == 4 ]]" mkvt; echo s > "$V/$U/homedir/shadow"; ln "$V/$U/homedir/shadow" "$V/$U/homedir/mail/$D/a/cur/in" check "verify: hard link INTO the mail tree (survived the pre-filter) => 4" "[[ \$(vrc) == 4 ]]" mkvt; ln "$V/$U/homedir/mail/$D/a/cur/1" "$V/cpmove-other/homedir/mail/$D/b/cur/1" check "verify: hard link between two accounts' mail trees => 4" "[[ \$(vrc) == 4 ]]" mkvt; rm -rf "$V/$U/homedir/mail"; ln -s /etc "$V/$U/homedir/mail" check "verify: homedir/mail itself a symlink => 4" "[[ \$(vrc) == 4 ]]" echo "extract.sh with IMPORT_INCLUDE_MAIL=1 (only \"true\" opts in)" OUT="$WORK/ex-one" IMPORT_INCLUDE_MAIL=1 bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-one.actions.json" >/dev/null 2>&1 check "IMPORT_INCLUDE_MAIL=1: mail tree skipped" "[[ ! -e '$OUT/$U/homedir/mail' ]]" # ---- scan-files.php with a stub clamscan ------------------------------------ # The stub reports one hit inside the mail tree and one in public_html. STUBBIN="$WORK/bin" mkdir -p "$STUBBIN" cat > "$STUBBIN/clamscan" <<'STUB' #!/usr/bin/env bash printf '%s\n' "$@" > "$CLAMSCAN_ARGS_OUT" dir="${!#}" top="${CLAM_TOP:-cpmove-testuser}" echo "$dir/$top/homedir/mail/example.com/alice/cur/1700000000.M1P1.host,S=40:2,S: Test.Mail.Sig FOUND" echo "$dir/$top/homedir/public_html/index.php: Test.Php.Sig FOUND" STUB chmod +x "$STUBBIN/clamscan" run_scan() { # [extra args] local ex="$1" tag="$2"; shift 2 mkdir -p "$WORK/q-$tag" CLAMSCAN_ARGS_OUT="$WORK/clam-$tag.args" PATH="$STUBBIN:$PATH" \ php "$REPO/scripts/scan-files.php" --extract "$ex" --quarantine "$WORK/q-$tag" \ --report "$WORK/scan-$tag.json" --import-id "t-$tag" "$@" 2>"$WORK/scan-$tag.log" } jq_php() { php -r '$r = json_decode(file_get_contents($argv[1]), true); echo json_encode($r[$argv[2]] ?? null);' "$1" "$2"; } echo "scan-files.php --skip-mail (mail tree extracted)" cp -a "$WORK/ex-mail" "$WORK/scan-skip" run_scan "$WORK/scan-skip" skip --skip-mail # Expected argv entry: the extract dir with ERE metacharacters escaped # (the only one mktemp's path can hold is "."), then the mail-tree pattern. SKIPDIR="$WORK/scan-skip" EXCL="--exclude-dir=^${SKIPDIR//./\\.}/[^/]+/homedir/mail(/.*)?\$" if grep -qxF -- "$EXCL" "$WORK/clam-skip.args"; then pass "--skip-mail: clamscan gets --exclude-dir for the mail tree" else fail "--skip-mail: clamscan gets --exclude-dir for the mail tree (want $EXCL; got: $(tr '\n' ' ' < "$WORK/clam-skip.args"))" fi check "--skip-mail: a hit inside the mail tree is NOT quarantined (message stays in the Maildir)" \ "[[ -f '$WORK/scan-skip/$U/homedir/mail/$D/alice/cur/1700000000.M1P1.host,S=40:2,S' ]]" check "--skip-mail: a hit outside the mail tree is still quarantined" \ "[[ ! -e '$WORK/scan-skip/$U/homedir/public_html/index.php' && -f '$WORK/q-skip/$U/homedir/public_html/index.php' ]]" check "--skip-mail: report files_quarantined = 1" "[[ \$(jq_php '$WORK/scan-skip.json' files_quarantined) == 1 ]]" check "--skip-mail: report mail_scan_skipped = true" "[[ \$(jq_php '$WORK/scan-skip.json' mail_scan_skipped) == true ]]" NONMAIL=$(cd "$WORK/scan-skip" && find . -type f ! -path "./$U/homedir/mail/*" | wc -l) check "--skip-mail: files_scanned counts only non-mail files ($NONMAIL, after the quarantine move)" \ "[[ \$(jq_php '$WORK/scan-skip.json' files_scanned) == $((NONMAIL + 1)) ]]" check "--skip-mail: the skip is logged with its reason" "grep -q 'NOT scanning the mail tree' '$WORK/scan-skip.log'" echo "I-1: top-level dirs other than cpmove-* (acct/, backup-_/)" for fx in acct:acct backupdir:backup-10.20.2026_10-00-00_testuser; do name="${fx%%:*}"; top="${fx#*:}" cp "$FIX/cpmove-$name.tar.gz" "$WORK/" OUT="$WORK/ex-$name" rc=0 IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$WORK/cpmove-$name.tar.gz" "$OUT" testuser "$WORK/ex-$name.actions.json" >"$WORK/ex-$name.log" 2>&1 || rc=$? M="$OUT/$top/homedir/mail/$D/alice" check "$name: extract exits 0 (rc=$rc)" "[[ $rc -eq 0 ]]" check "$name: relative symlinks in the Maildir stripped (.evil-rel, .Other)" "[[ ! -L '$M/.evil-rel' && ! -L '$M/.Other' ]]" check "$name: no symlink at all under $top/homedir/mail" "[[ -z \$(find '$OUT/$top/homedir/mail' -type l) ]]" check "$name: hard link INTO the mail tree stripped" "[[ ! -e '$M/cur/leak' ]]" check "$name: in-tree Dovecot hard link kept" "[[ -f '$M/cur/1700000002.M3P1.host,S=41:2,S' ]]" check "$name: strips recorded as stripped_mail_link" "grep -q '$top/homedir/mail/$D/alice/.evil-rel' '$WORK/ex-$name.actions.json'" rm -rf "$WORK/q-$name" CLAM_TOP="$top" CLAMSCAN_ARGS_OUT="$WORK/clam-$name.args" PATH="$STUBBIN:$PATH" \ php "$REPO/scripts/scan-files.php" --extract "$OUT" --quarantine "$WORK/q-$name" \ --report "$WORK/scan-$name.json" --import-id "t-$name" --skip-mail 2>/dev/null check "$name: --skip-mail keeps a hit inside $top/homedir/mail in the Maildir" "[[ -f '$M/cur/1700000000.M1P1.host,S=40:2,S' ]]" check "$name: --skip-mail still quarantines public_html" "[[ ! -e '$OUT/$top/homedir/public_html/index.php' ]]" done mkvt; mkdir -p "$V/acct/homedir/mail/$D/a/cur"; ln -s ../../../../../etc "$V/acct/homedir/mail/$D/a/x" check "verify: symlink under acct/homedir/mail => 4" "[[ \$(vrc) == 4 ]]" mkvt; ln -s "$U" "$V/zz-link" check "verify: top-level symlink => 4 (never walked through)" "[[ \$(vrc) == 4 ]]" if [[ -n "${WHP_REPO:-}" ]]; then echo "M-4: contract with the WHP panel at \$WHP_REPO" SAN="$WHP_REPO/web-files/libs/CpanelImportSanitizationContainer.php" check "WHP sends the env name this image reads (INCLUDE_MAIL_ENV = 'IMPORT_INCLUDE_MAIL')" "grep -q \"INCLUDE_MAIL_ENV = 'IMPORT_INCLUDE_MAIL'\" '$SAN'" check "WHP sends exactly '=true'" "grep -q \"INCLUDE_MAIL_ENV . '=true'\" '$SAN'" check "WHP reads the report key this image writes (mail_included === true)" "grep -q \"\\['mail_included'\\] ?? null) === true\" '$SAN'" check "this image writes mail_included" "grep -q '\"mail_included\"' '$REPO/scripts/entrypoint.sh'" fi echo "scan-files.php without --skip-mail (unchanged behaviour)" cp -a "$WORK/ex-mail" "$WORK/scan-noskip" run_scan "$WORK/scan-noskip" noskip check "no flag: no --exclude-dir passed" "! grep -q -- '--exclude-dir' '$WORK/clam-noskip.args'" check "no flag: report mail_scan_skipped = false" "[[ \$(jq_php '$WORK/scan-noskip.json' mail_scan_skipped) == false ]]" check "no flag: both hits quarantined" "[[ \$(jq_php '$WORK/scan-noskip.json' files_quarantined) == 2 ]]" echo if [[ $FAILS -eq 0 ]]; then echo "ALL PASS"; else echo "$FAILS FAILURE(S)"; fi exit $(( FAILS == 0 ? 0 : 1 ))