diff --git a/docs/superpowers/contracts/nc-php-suite-2026-08-22.json b/docs/superpowers/contracts/nc-php-suite-2026-08-22.json new file mode 100644 index 00000000..e0b8e26f --- /dev/null +++ b/docs/superpowers/contracts/nc-php-suite-2026-08-22.json @@ -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)." +} diff --git a/docs/superpowers/evidence/nc-php-suite-2026-08-22.json b/docs/superpowers/evidence/nc-php-suite-2026-08-22.json new file mode 100644 index 00000000..130b3040 --- /dev/null +++ b/docs/superpowers/evidence/nc-php-suite-2026-08-22.json @@ -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//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." + ] +} diff --git a/linux_configuration/scripts/single_use/features/lib/tests/run_all.sh b/linux_configuration/scripts/single_use/features/lib/tests/run_all.sh index 71b38e33..05eaed4f 100755 --- a/linux_configuration/scripts/single_use/features/lib/tests/run_all.sh +++ b/linux_configuration/scripts/single_use/features/lib/tests/run_all.sh @@ -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 .sh --min 100 -- "" set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/linux_configuration/scripts/single_use/features/lib/tests/test_nc_php.sh b/linux_configuration/scripts/single_use/features/lib/tests/test_nc_php.sh new file mode 100755 index 00000000..1fb24301 --- /dev/null +++ b/linux_configuration/scripts/single_use/features/lib/tests/test_nc_php.sh @@ -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//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" diff --git a/meta/scripts/lib/shell_coverage_jail_setup.sh b/meta/scripts/lib/shell_coverage_jail_setup.sh index 16d97219..3abc60a8 100755 --- a/meta/scripts/lib/shell_coverage_jail_setup.sh +++ b/meta/scripts/lib/shell_coverage_jail_setup.sh @@ -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 diff --git a/meta/scripts/shell_coverage_jail.sh b/meta/scripts/shell_coverage_jail.sh index 3cb9cfbb..7d471c45 100755 --- a/meta/scripts/shell_coverage_jail.sh +++ b/meta/scripts/shell_coverage_jail.sh @@ -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 [options] -- [...] Use for every path the subject WRITES. Do NOT bind a path the subject READS from -- that masks its inputs. --shim additionally stub on PATH; repeatable + --seed-dir create 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 create an empty file at inside the jail --min fail below this (default 100) -- ... 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"