Cover nc_php.sh (98.82%) and teach the jail to measure foreign platforms

nc_php.sh was the next-hardest library on the allowlist. It targets a
Debian Raspberry Pi -- it edits /etc/php/<ver>/apache2/php.ini, drives
occ as www-data and installs a crontab -- so none of its write targets
exist on this Arch host and the jail had nothing to mirror.

- --seed-dir / --seed-file create the paths a foreign-platform subject
  expects. Verified with a probe: /etc/php/8.2/apache2 exists inside
  the jail while /etc/php does not exist outside it.
- A failed bind is now FATAL, and missing mountpoints are created
  first. A bind that fails silently is indistinguishable from
  unreachable code in the report, which is the misdiagnosis this
  runner exists to prevent.
- Mirroring skips unreadable targets (/root as a normal user) rather
  than aborting under set -e.

nc_php.sh: 84/85 = 98.82%, 24 assertions. It STAYS on the allowlist,
because 98.82% is below the bar. Line 111 -- the crontab first-run
fallback -- hung the suite on both attempts to cover it, so it is left
uncovered and documented rather than tried a third time.

Two harness hazards found the hard way and written down: a top-level
stub function shadows the subject's own function for every later
assertion (this silently dropped coverage 83.53% -> 57.65% while the
tests still passed), and a stub for a piped-into command must drain
stdin or the writer takes SIGPIPE.

dot_resolver_install.sh still measures 39/39 = 100.00%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbD4nco3EgL1UFoZKmKsLE
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 11:30:32 +02:00
parent bca70b67b5
commit 61ff5eacac
6 changed files with 300 additions and 5 deletions

View File

@ -0,0 +1,19 @@
{
"title": "Cover nc_php.sh and teach the jail runner to measure foreign-platform subjects",
"objective": "Extend the namespace-jail runner with the path seeding and bind-failure handling needed to measure a library written for another distribution, and use it to bring nc_php.sh -- the next-hardest library on the allowlist -- as close to the 100% bar as its code allows.",
"acceptance_criteria": [
"nc_php.sh measures at least 98% through its new suite, run inside the jail with the documented binds and seeds.",
"dot_resolver_install.sh still measures 39/39 = 100.00% after every runner change, proving no regression.",
"A bind-mount that cannot be established fails the run instead of warning, so a silent bind failure can never be reported as unreachable code.",
"--seed-dir creates a directory that does not exist on the host, verified by a probe asserting /etc/php/8.2/apache2 exists inside the jail while /etc/php does not exist outside it.",
"shellcheck is clean on every new and modified file, with no suppression added.",
"The real /etc, /var and /root are unmodified after every run; the jail's escape assertion stays armed."
],
"out_of_scope": [
"Removing nc_php.sh from the allowlist: at 98.82% it is below the 100% bar and must stay exempt until line 111 is covered.",
"Covering line 111 (the crontab first-run fallback). Two attempts hung the suite for the full timeout; it is left uncovered and documented rather than attempted a third time.",
"Wiring the gate into the hook chain.",
"Modifying nc_php.sh itself -- the jail is meant to reach production code unchanged."
],
"verifier": "bash meta/scripts/shell_coverage_jail.sh --subject linux_configuration/scripts/single_use/features/lib/tests/run_all.sh --bind /etc --bind /usr/local/bin --bind /var --bind /root --seed-dir /var/www/nextcloud --seed-dir /var/lib --seed-dir /etc/php/8.2/apache2 --seed-dir /etc/php/8.2/mods-available --seed-file /etc/php/8.2/apache2/php.ini --measure nc_php.sh --min 0 -- \"\" (expects 84/85 = 98.82%), then the same command with --measure dot_resolver_install.sh --min 100 (expects 39/39)."
}

View File

@ -0,0 +1,46 @@
{
"intent": "Continue the Phase 3 campaign by covering the next-hardest shell library, nc_php.sh, and extend the jail runner with the seeding and bind-failure handling that a foreign-platform subject requires.",
"scope": [
"linux_configuration/scripts/single_use/features/lib/tests/test_nc_php.sh (new)",
"meta/scripts/shell_coverage_jail.sh + lib/shell_coverage_jail_setup.sh: --seed-dir/--seed-file, fatal bind failures, tolerate unreadable bind targets",
"Non-goal: removing nc_php.sh from the allowlist -- it is at 98.82%, below the 100% bar, so it stays exempt"
],
"changes": [
"Added --seed-dir and --seed-file so a subject targeting another distribution can be measured: nc_php.sh edits /etc/php/<ver>/apache2/php.ini and installs to /var/www/nextcloud, none of which exist on this Arch host.",
"A failed bind-mount is now fatal instead of a warning, and missing mountpoints are created first. A silent bind failure is indistinguishable from unreachable code in the report, which is the exact misdiagnosis this runner exists to prevent.",
"Directory mirroring now skips unreadable bind targets (/root as a normal user) rather than aborting under set -e.",
"Added test_nc_php.sh: 24 assertions over configure_php, configure_redis, install_nextcloud, setup_nextcloud_cron, verify_nextcloud and phase_nextcloud."
],
"verification": [
{
"command": "shell_coverage_jail.sh --measure nc_php.sh --min 0 with the documented binds and seeds",
"result": "pass - 84/85",
"evidence": "nc_php.sh: 84/85 lines = 98.82%. Only line 111 is uncovered: the `crontab -u www-data -l || echo \"\" | crontab -u www-data -` first-run fallback."
},
{
"command": "shell_coverage_jail.sh --measure dot_resolver_install.sh --min 100 after all runner changes",
"result": "pass - no regression",
"evidence": "dot_resolver_install.sh: 39/39 lines = 100.00%, unchanged by the seeding and bind-failure work."
},
{
"command": "run the suite inside the jail and read its assertions",
"result": "pass - the install order is pinned",
"evidence": "phase_nextcloud asserts 'root deps mariadb download apache php redis install cron verify', so php and redis must both be configured before the installer runs and cron must precede verification."
},
{
"command": "shellcheck on the new and modified files",
"result": "pass - clean, no suppressions",
"evidence": "test_nc_php.sh, shell_coverage_jail.sh and shell_coverage_jail_setup.sh all clean."
}
],
"risks": [
"Line 111 of nc_php.sh is NOT covered, so nc_php.sh remains on the allowlist. Two attempts to cover it both hung the suite for the full timeout: with `crontab -l` failing, the fallback's second `crontab -` inherits the suite's stdin and blocks, and redirecting the call from /dev/null did not prevent it. Left uncovered deliberately rather than attempted a third time.",
"Stub functions defined at the top level of a suite SHADOW the subject's own functions for every later assertion. Doing this for the phase_nextcloud order test dropped coverage from 83.53% to 57.65% while the tests still reported passing, because they were exercising the stubs. The fix is a subshell, and the hazard is general to this style of harness.",
"A stub for a command the subject PIPES into must drain stdin, or the writer takes SIGPIPE and the suite aborts under set -e. A stub whose output is piped into `grep -v` must also emit a line, since grep exits 1 on no match and pipefail propagates it.",
"The bind list for this suite (/etc, /usr/local/bin, /var, /root) is wider than dot_resolver's. Binding all of /var rather than /var/lib and /var/www separately was necessary because a bind onto a non-existent host path cannot be created under a read-only real /var."
],
"rollback": [
"git revert this commit; nothing is wired into a hook, so no commit path can break.",
"After rollback, confirm dot_resolver_install.sh still measures 39/39 through the runner."
]
}

View File

@ -7,6 +7,17 @@
# These suites execute their subjects FOR REAL, including sudo-writes to
# /etc/systemd/system and nftables rules. They must therefore run inside the
# namespace jail; see the header of test_dot_resolver_install.sh.
#
# The full invocation, including the seeds the Debian/Raspberry-Pi subjects
# need on an Arch host:
#
# meta/scripts/shell_coverage_jail.sh \
# --subject linux_configuration/scripts/single_use/features/lib/tests/run_all.sh \
# --bind /etc --bind /usr/local/bin --bind /var/lib \
# --seed-dir /etc/php/8.2/apache2 \
# --seed-dir /etc/php/8.2/mods-available \
# --seed-file /etc/php/8.2/apache2/php.ini \
# --measure <lib>.sh --min 100 -- ""
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

View File

@ -0,0 +1,184 @@
#!/usr/bin/env bash
# Tests for nc_php.sh — PHP, Redis and the Nextcloud install itself.
#
# The subject targets a Raspberry Pi running Debian: it edits
# /etc/php/<ver>/apache2/php.ini, drives `occ` as www-data, and installs a
# crontab. None of those paths exist on this Arch host, so the jail is given
# them with --seed-dir/--seed-file; see run_all.sh's header for the exact
# invocation. Run outside the jail this would edit a php.ini that is not
# there, which is why the guard below refuses.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
# The seeded php.ini only exists inside the jail. Its absence means we are not
# contained, and configure_php would `cp` a file that is not there.
PHP_VERSION_UNDER_TEST="8.2"
PHP_INI="/etc/php/${PHP_VERSION_UNDER_TEST}/apache2/php.ini"
if [[ ! -f $PHP_INI ]]; then
printf 'REFUSING: %s is absent; this suite must run under the jail\n' "$PHP_INI" >&2
exit 1
fi
_t_setup_env
trap _t_teardown EXIT
# Globals the entry script defines above its source line.
NEXTCLOUD_ADMIN_USER="admin"
NEXTCLOUD_ADMIN_PASSWORD="hunter2"
NEXTCLOUD_DATA_DIR="/var/nextcloud-data"
PI_HOSTNAME="raspberrypi"
# The logging helpers the libs inherit from the caller.
log_info() { printf '[INFO] %s\n' "$*"; }
log_success() { printf '[OK] %s\n' "$*"; }
log_warning() { printf '[WARN] %s\n' "$*"; }
log_error() { printf '[ERR] %s\n' "$*"; }
# shellcheck source=../nc_php.sh
. "$FEATURES_LIB_DIR/nc_php.sh"
[[ -n $NEXTCLOUD_DATA_DIR && -n $PI_HOSTNAME ]] ||
{
printf 'fixture globals are not populated\n' >&2
exit 1
}
# --- configure_php ----------------------------------------------------------
# php reports the seeded version; apache2 restart is recorded, not performed.
printf '#!/usr/bin/env bash\nprintf "%%s" "%s"\n' "$PHP_VERSION_UNDER_TEST" \
>"$TEST_TMPDIR/bin/php"
chmod +x "$TEST_TMPDIR/bin/php"
_t_stub systemctl
cat >"$PHP_INI" <<'INI'
memory_limit = 128M
upload_max_filesize = 2M
post_max_size = 8M
max_execution_time = 30
max_input_time = 60
;date.timezone =
INI
mkdir -p "/etc/php/${PHP_VERSION_UNDER_TEST}/mods-available"
: >"/etc/php/${PHP_VERSION_UNDER_TEST}/mods-available/apcu.ini"
configure_php
_t_file_has "$PHP_INI" 'memory_limit = 512M' "memory_limit raised for Nextcloud"
_t_file_has "$PHP_INI" 'upload_max_filesize = 16G' "upload limit raised to 16G"
_t_file_has "$PHP_INI" 'post_max_size = 16G' "post_max_size raised to 16G"
_t_file_has "$PHP_INI" 'max_execution_time = 360' "execution time raised"
_t_file_has "$PHP_INI" 'date.timezone = Europe/Warsaw' "timezone set (the ;-prefixed default is replaced)"
_t_file_has "$PHP_INI" 'opcache.enable=1' "OPcache enabled"
_t_file_has "$PHP_INI" 'opcache.save_comments=1' "OPcache keeps comments (Nextcloud needs them)"
_t_file_has "${PHP_INI}.backup" 'memory_limit = 128M' "the original php.ini is backed up before editing"
_t_file_has "/etc/php/${PHP_VERSION_UNDER_TEST}/mods-available/apcu.ini" 'apc.enable_cli=1' "APCu enabled for the CLI"
_t_called 'restart apache2' "apache is restarted to pick up the new ini"
# --- configure_redis --------------------------------------------------------
configure_redis
_t_called 'enable redis-server' "redis is enabled at boot"
_t_called 'start redis-server' "redis is started now"
# --- install_nextcloud ------------------------------------------------------
mkdir -p /root /var/www/nextcloud
printf 'dbsecret\n' >/root/.nextcloud_db_password
printf '#!/usr/bin/env bash\nprintf "10.0.0.5 \\n"\n' >"$TEST_TMPDIR/bin/hostname"
chmod +x "$TEST_TMPDIR/bin/hostname"
_t_stub chown
# `occ` is invoked as `php occ ...`; the php stub above must now record instead
# of printing a version, or every occ call would be silently swallowed.
_t_stub php
install_nextcloud
_t_called 'occ maintenance:install' "the installer is run"
_t_called 'admin-user admin' "the configured admin user is passed through"
_t_called 'trusted_domains 1 --value=10.0.0.5' "the server IP is trusted"
_t_called "trusted_domains 2 --value=$PI_HOSTNAME" "the hostname is trusted"
_t_called "trusted_domains 3 --value=${PI_HOSTNAME}.local" "the .local name is trusted"
_t_called 'memcache.locking' "Redis is wired in for locking"
_t_called 'default_phone_region' "the phone region is set"
if [[ -d $NEXTCLOUD_DATA_DIR ]]; then
_t_pass "the data directory is created"
else
_t_fail "the data directory is created"
fi
# An empty admin password must be prompted for rather than silently accepted.
prompt_password() {
printf '[prompt] %s\n' "$1"
printf -v "$2" '%s' "prompted-secret"
}
NEXTCLOUD_ADMIN_PASSWORD=""
out="$(install_nextcloud 2>&1)"
_t_has "$out" '[prompt] Enter Nextcloud admin password' "an empty admin password is prompted for"
_t_called 'admin-pass prompted-secret' "the prompted password reaches the installer"
NEXTCLOUD_ADMIN_PASSWORD="hunter2"
# --- setup_nextcloud_cron ---------------------------------------------------
# setup_nextcloud_cron PIPES into crontab. A stub that exits without reading
# stdin makes the writer take SIGPIPE, which aborts the suite under `set -e`
# -- so this one drains stdin before recording.
cat >"$TEST_TMPDIR/bin/crontab" <<'CRONTAB'
#!/usr/bin/env bash
cat >/dev/null
printf 'crontab %s\n' "$*" >>"$TEST_TMPDIR/calls.log"
# `-l` must emit at least one line: the real code pipes it through
# `grep -v nextcloud/cron.php`, and grep exits 1 on no match, which
# aborts the caller under pipefail. An existing unrelated entry is also
# the realistic case -- it proves the rewrite PRESERVES other cron jobs.
if [[ $* == *-l* ]]; then
printf '0 4 * * * /usr/local/bin/some-unrelated-job\n'
fi
exit 0
CRONTAB
chmod +x "$TEST_TMPDIR/bin/crontab"
setup_nextcloud_cron
_t_called 'crontab -u www-data' "the www-data crontab is written"
_t_called 'occ background:cron' "Nextcloud is switched to cron mode"
_t_called 'nextcloud/cron.php' "the Nextcloud cron entry is installed"
# --- phase_nextcloud --------------------------------------------------------
# The orchestrator. Its siblings live in other libs, so the fixture supplies
# them; what is asserted here is the ORDER, which is load-bearing: php and
# redis must both be configured before the installer runs, and the cron job
# must be added before the final verification.
#
# Run in a SUBSHELL. These definitions shadow the real functions, and letting
# them leak into the enclosing shell silently replaced the subject's own
# functions for every later assertion -- coverage fell from 83.53% to 57.65%
# and the tests still "passed", because they were exercising the stubs.
phase_order_result="$(
check_root() { printf 'root '; }
install_nextcloud_dependencies() { printf 'deps '; }
configure_mariadb() { printf 'mariadb '; }
download_nextcloud() { printf 'download '; }
configure_apache() { printf 'apache '; }
configure_php() { printf 'php '; }
configure_redis() { printf 'redis '; }
install_nextcloud() { printf 'install '; }
setup_nextcloud_cron() { printf 'cron '; }
verify_nextcloud() { printf 'verify '; }
log_info() { :; }
log_success() { :; }
phase_nextcloud
)"
_t_eq "root deps mariadb download apache php redis install cron verify " \
"$phase_order_result" "the install phases run in dependency order"
# --- verify_nextcloud -------------------------------------------------------
printf '#!/usr/bin/env bash\nprintf "200"\n' >"$TEST_TMPDIR/bin/curl"
chmod +x "$TEST_TMPDIR/bin/curl"
out="$(verify_nextcloud 2>&1)"
_t_has "$out" 'Nextcloud is responding!' "a 200 from status.php reads as healthy"
_t_has "$out" 'Access Nextcloud at: http://10.0.0.5' "the access URL is reported"
# A non-200 must warn, not claim success.
printf '#!/usr/bin/env bash\nprintf "503"\n' >"$TEST_TMPDIR/bin/curl"
chmod +x "$TEST_TMPDIR/bin/curl"
out="$(verify_nextcloud 2>&1)"
_t_has "$out" 'may not be fully ready' "a non-200 warns instead of claiming success"
_t_report "test_nc_php.sh"

View File

@ -67,7 +67,10 @@ SUDO
local path idx=0
for path in ${BIND_PATHS[@]+"${BIND_PATHS[@]}"}; do
mkdir -p "$JAIL/mnt$idx"
if [[ -d $path ]]; then
# -r: an unreadable target (/root as a normal user) simply has no tree
# to mirror. That is not an error -- the caller seeds what it needs with
# --seed-dir -- but cd'ing into it would abort under `set -e`.
if [[ -d $path && -r $path && -x $path ]]; then
(cd "$path" && find . -type d -not -path '*/.*' -print0 2>/dev/null) |
(cd "$JAIL/mnt$idx" && xargs -0 -r mkdir -p 2>/dev/null) || true
fi
@ -92,7 +95,13 @@ build_mount_script() {
local path idx=0
{
for path in ${BIND_PATHS[@]+"${BIND_PATHS[@]}"}; do
printf 'mount --bind %q %q 2>/dev/null || printf "warn: bind failed: %s\\n" %q >&2\n' \
# mkdir first: a bind onto a path this host does not have (/var/www on
# a machine with no webserver) fails outright, and a bind that fails
# silently is the exact misdiagnosis this runner exists to avoid --
# the subject then writes to the REAL path or dies, and the report
# shows unreachable lines either way. Hence fatal, not a warning.
printf 'mkdir -p %q 2>/dev/null || true\n' "$path"
printf 'mount --bind %q %q || { printf "Error: bind failed: %s\\n" %q >&2; exit 1; }\n' \
"$JAIL/mnt$idx" "$path" '%s' "$path"
idx=$((idx + 1))
done

View File

@ -40,6 +40,8 @@ readonly REPO_ROOT
SUBJECT=""
MEASURE=""
SEED_DIRS=()
SEED_FILES=()
MIN_PERCENT=100
BIND_PATHS=()
EXTRA_SHIMS=()
@ -57,6 +59,11 @@ Usage: $SCRIPT_NAME --subject <file> [options] -- <case> [<case>...]
Use for every path the subject WRITES. Do NOT bind a
path the subject READS from -- that masks its inputs.
--shim <name> additionally stub <name> on PATH; repeatable
--seed-dir <path> create <path> inside the jail; repeatable. Needed when a
subject targets a platform this host is not -- a Debian
script writing /etc/php/8.2/apache2/ finds nothing to
mirror on an Arch box.
--seed-file <path> create an empty file at <path> inside the jail
--min <percent> fail below this (default 100)
-- <case>... one invocation each; "" means "no arguments"
@ -86,6 +93,14 @@ while [[ $# -gt 0 ]]; do
EXTRA_SHIMS+=("$2")
shift 2
;;
--seed-dir)
SEED_DIRS+=("$2")
shift 2
;;
--seed-file)
SEED_FILES+=("$2")
shift 2
;;
--min)
MIN_PERCENT="$2"
shift 2
@ -133,9 +148,8 @@ cleanup() {
}
trap cleanup EXIT
# Effecting binaries are recorded, never executed. Anything that installs,
# reboots, kills or reconfigures the host belongs here.
# Jail construction lives in lib/shell_coverage_jail_setup.sh (250-line cap).
# Jail construction, including the shim list, lives in
# lib/shell_coverage_jail_setup.sh (250-line cap).
# shellcheck source=./lib/shell_coverage_jail_setup.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib/shell_coverage_jail_setup.sh"
@ -185,6 +199,18 @@ done <"$jail/cases"
INNER
chmod +x "$JAIL/run_cases.sh"
# Seeds are created after the mounts, from inside the namespace: a path
# under a bound target does not exist until that target is mounted.
{
local seed
for seed in ${SEED_DIRS[@]+"${SEED_DIRS[@]}"}; do
printf 'mkdir -p %q\n' "$seed"
done
for seed in ${SEED_FILES[@]+"${SEED_FILES[@]}"}; do
printf 'mkdir -p %q && : >%q\n' "$(dirname "$seed")" "$seed"
done
} >>"$JAIL/mounts.sh"
unshare --user --map-root-user --mount --fork \
"$JAIL/run_cases.sh" "$JAIL" "$subject_dir" "$subject_base" "$measure_base"