#!/bin/sh
# Names every file a unit test writes, makes, renames or deletes outside
# the tests folder.
#
# check_test_writes.sh every test file
# check_test_writes.sh tests/FooTest.php the named test files
#
# WHY THIS EXISTS. A test case writes only inside the tests folder. One that wrote the site's own app/css/search.css failed once
# in a full run, and tests had written the site's work directory, /tmp and
# /root. Reading the tests for such writes misses the ones the code under
# test makes, so this runs each test file under strace, which logs every
# file the process and its children open for writing, make, rename or
# delete. It prints the full path of each one outside the tests folder,
# with its test file and how many times it was touched.
#
# Every run opens the site's two databases, public_default.db and
# private_default.db, in the mode that would make them if missing. That is
# reading, not writing, and SQLite makes their -wal and -shm files when it
# opens them, so these are named only when the bytes of a database or its
# -wal file change.
#
# strace is a Linux program; python3 reads its log. Each test file runs
# under timeout 120. Exits non-zero when a test writes outside tests/.
cd "$(dirname "$0")/../.." || exit 2
for program in strace python3; do
if ! command -v $program >/dev/null 2>&1; then
echo "check_test_writes: $program is not installed"
exit 2
fi
done
if [ $# -eq 0 ]; then
set -- tests/*Test.php
fi
root=$(pwd -P)
# the work directory is work_directory beside src, or the folder WORK names
work=$(cd "${WORK:-$root/work_directory}" && pwd -P) || exit 2
log="$work/temp/check_test_writes.$$"
found=0
for test_file in "$@"; do
name=$(basename "$test_file" .php)
before=$(cat "$work"/data/*_default.db "$work"/data/*_default.db-wal \
2>/dev/null | cksum)
timeout 120 strace -f -qq -y -o "$log" -e trace=openat,unlink,unlinkat,\
rename,renameat,renameat2,mkdir,mkdirat,rmdir,symlink,symlinkat,link,\
linkat php src/executables/CodeTool.php unit "$name" >/dev/null 2>&1
after=$(cat "$work"/data/*_default.db "$work"/data/*_default.db-wal \
2>/dev/null | cksum)
changed=0
[ "$before" = "$after" ] || changed=1
out=$(python3 - "$log" "$root" "$work" "$changed" <<'EOF'
import os, re, sys, collections
log, root, work, changed = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
allowed = [os.path.join(root, "tests") + os.sep]
databases = [os.path.join(work, "data", name + ending) for name in
("public_default.db", "private_default.db") for ending in
("", "-wal", "-shm")]
writes = re.compile(r'O_WRONLY|O_CREAT|O_TRUNC')
counts = collections.Counter()
for line in open(log, errors="replace"):
found = re.match(r'\d+\s+(\w+)\((.*)\)\s+=\s+(-?\d+)', line)
if not found or found.group(3).startswith("-"):
continue
call, args = found.group(1), found.group(2)
paths = re.findall(r'"((?:[^"\\]|\\.)*)"', args)
if call == "openat":
if not writes.search(args):
continue
paths = paths[:1]
elif call in ("symlink", "symlinkat"):
paths = paths[-1:]
# strace -y writes a folder given by number as 3</its/path>, and a
# relative name in a call ending in at is read from that folder
folder = re.match(r'\d+<([^>]*)>', args)
base = folder.group(1) if folder else root
for path in paths:
full = os.path.realpath(os.path.join(base, path))
if full.startswith(("/dev/", "/proc/")) or \
any(full.startswith(place) for place in allowed):
continue
if full in databases and changed == "0":
continue
counts[full] += 1
for full, count in sorted(counts.items()):
print(" %s (%d)" % (full, count))
EOF
)
if [ -n "$out" ]; then
found=1
echo "$name:"
echo "$out"
fi
done
rm -f "$log"
if [ "$found" -ne 0 ]; then
echo "check_test_writes: the tests above write outside $root/tests"
exit 1
fi
echo "check_test_writes: every test writes only inside $root/tests"