diff --git a/docs/superpowers/contracts/hosts-guard-tests-2026-08-23.json b/docs/superpowers/contracts/hosts-guard-tests-2026-08-23.json new file mode 100644 index 00000000..256e065a --- /dev/null +++ b/docs/superpowers/contracts/hosts-guard-tests-2026-08-23.json @@ -0,0 +1,19 @@ +{ + "title": "Clear both hosts-guard libs and fix the done-redirect instrument artifact", + "objective": "Bring hosts_guard_migrate.sh and hosts_guard_rollback.sh to 100% measured line coverage and remove both allowlist exemptions, extending continuation_lines() so a loop terminator carrying a redirect stops being counted as an unreachable statement.", + "acceptance_criteria": [ + "Both libs measure 100.00% through tests/run_all.sh at --min 100 with --fail-on-case-error, exiting 0.", + "check_shell_coverage.sh passes on both with their allowlist lines already deleted.", + "The new _LOOP_END_OP predicate excludes ONLY lines where `done` is followed by a redirect or pipe; a bare `done` stays in the denominator so real uncovered loops are still caught.", + "An audit printing every newly-excluded line with its text shows exactly the two lines in hosts_guard_rollback.sh and nothing else across fixes/lib.", + "The instrument acceptance pair still measures exactly: dot_resolver_install.sh 39/39 and rpi_nc_ca.sh 73/73, through features/lib/tests/run_all.sh with its jail_args.", + "Both libs keep their production behaviour: every new seam defaults to the real path it replaced.", + "The suite stays green and shellcheck reports nothing, with zero suppressions." + ], + "out_of_scope": [ + "The backslash-continued-assignment artifact that caps bt_adapter.sh at 98.55%; it is a different predicate and only affects one line in one lib.", + "arch_sysctl_quarantined_cases.sh, which stays outside the suite.", + "The last two untested libs, ubuntu_perf_fixes.sh and nvidia_config.sh." + ], + "verifier": "timeout 900s bash meta/scripts/shell_coverage_jail.sh --timeout 600s --subject linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh --measure hosts_guard_migrate.sh (then hosts_guard_rollback.sh) --min 100 --fail-on-case-error -- \"\", then the acceptance pair through features/lib/tests/run_all.sh, then check_shell_coverage.sh on both libs." +} diff --git a/docs/superpowers/evidence/hosts-guard-tests-2026-08-23.json b/docs/superpowers/evidence/hosts-guard-tests-2026-08-23.json new file mode 100644 index 00000000..3fd49310 --- /dev/null +++ b/docs/superpowers/evidence/hosts-guard-tests-2026-08-23.json @@ -0,0 +1,63 @@ +{ + "intent": "Test both hosts-guard migration libraries to 100% line coverage, take them off the allowlist, and teach the coverage instrument that a loop terminator carrying a redirect is not a statement.", + "scope": [ + "linux_configuration/scripts/single_use/fixes/lib/hosts_guard_migrate.sh (two test seams)", + "linux_configuration/scripts/single_use/fixes/lib/hosts_guard_rollback.sh (one test seam)", + "linux_configuration/scripts/single_use/fixes/lib/tests/hosts_guard_harness.sh and three test files (new)", + "meta/scripts/lib/shell_coverage_lines.py (_LOOP_END_OP predicate)", + "meta/shell-coverage-allowlist.txt (two lines removed)" + ], + "changes": [ + "Added SYSTEMD_UNIT_DIR and PACMAN_DB_LCK seams to hosts_guard_migrate.sh and HOSTS_FILE to hosts_guard_rollback.sh, each defaulting to the real path, so validate_requirements and do_rollback can be driven without a real /etc or a real pacman lock", + "Added hosts_guard_harness.sh, the fifth family harness, supplying the path globals, the LEGACY_HOOKS/LEGACY_UNITS/INSTANCES tables and the msg/note/warn/err/run/instance_* helpers that live in the entry script", + "Added 82 assertions over 36 cases across three test files covering both libs", + "INSTRUMENT FIX: added _LOOP_END_OP to continuation_lines(), excluding a loop terminator that carries a redirect (`done >file`, `done \"$1\"`, measured with kcov directly", + "result": "pass", + "evidence": "The loop body reports hits=3 and the `done >\"$1\"` line reports hits=0 in the same run, confirming bash attributes the redirect to the loop's opening line. This is the same defect _CLOSING_BRACE_OP already handles for `} |`." + }, + { + "command": "Audit: print every line the new predicate newly excludes, with its text, across all of fixes/lib", + "result": "pass", + "evidence": "Exactly two lines: hosts_guard_rollback.sh:69 'done >\"$STATE_DIR/units.state\"' and :108 'done <\"$STATE_DIR/units.state\"'. No over-exclusion, which is the only direction that could inflate coverage." + }, + { + "command": "Acceptance suite for any instrument change, through features/lib/tests/run_all.sh with its jail_args", + "result": "pass", + "evidence": "dot_resolver_install.sh: 39/39 = 100.00% and rpi_nc_ca.sh: 73/73 = 100.00% -- both exactly the documented numbers, so the predicate changed no other measurement." + }, + { + "command": "shell_coverage_jail.sh --measure hosts_guard_migrate.sh / hosts_guard_rollback.sh --min 100 --fail-on-case-error", + "result": "pass", + "evidence": "hosts_guard_migrate.sh: 81/81 = 100.00%. hosts_guard_rollback.sh: 65/65 = 100.00% (denominator 67 -> 65 from the predicate; it measured 97.01% with lines 69 and 108 uncovered beforehand)." + }, + { + "command": "bash meta/scripts/check_shell_coverage.sh on both libs, AFTER deleting their allowlist lines", + "result": "pass", + "evidence": "'every checked shell library meets the coverage bar', EXIT 0." + }, + { + "command": "bash linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh", + "result": "pass", + "evidence": "All fixes lib test files passed; 98s bare with the three new files." + }, + { + "command": "pre-commit run ruff --files meta/scripts/lib/shell_coverage_lines.py", + "result": "pass", + "evidence": "Passed. A bare `ruff check` reports CPY001 on this file, but it does so on the unmodified file from HEAD as well -- it is a config-less invocation artifact, not a regression." + } + ], + "risks": [ + "The instrument change lowers denominators wherever `done ` appears. A lib already at 100% cannot regress from excluding more lines (covered_set is intersected with the denominator), and the audit shows only two lines repo-wide are affected, both in this commit's own lib.", + "The predicate REQUIRES a redirect after `done`; a bare `done` stays in the denominator, so ordinary uncovered loops are still caught.", + "hosts_guard_harness.sh redefines instance_spec with tmpdir targets rather than /etc paths, so the tests exercise the real control flow against fake targets; a change to the real instance table would not be caught by these tests." + ], + "rollback": [ + "git revert the commit, then re-add both hosts_guard lines to meta/shell-coverage-allowlist.txt", + "Re-run the acceptance pair (dot_resolver_install.sh 39/39, rpi_nc_ca.sh 73/73) to confirm the instrument is back to its prior behaviour" + ] +} diff --git a/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_migrate.sh b/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_migrate.sh index fffea1dc..e3f24edf 100755 --- a/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_migrate.sh +++ b/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_migrate.sh @@ -8,6 +8,9 @@ # Preconditions # ---------------------------------------------------------------------------- validate_requirements() { + # SYSTEMD_UNIT_DIR and PACMAN_DB_LCK default to the real locations and + # are overridden only by the test harness, which runs un-jailed in + # ci_mirror.sh and in CI where a bind mount would protect nothing. [[ -x $GUARDCTL ]] || { err "guardctl not found at $GUARDCTL - run guard-lib's install.sh first" exit 1 @@ -15,8 +18,8 @@ validate_requirements() { local unit for unit in guard-file@.path guard-file@.service guard-bind-mount@.service; do - [[ -f "/etc/systemd/system/$unit" ]] || { - err "missing systemd template /etc/systemd/system/$unit - run guard-lib's install.sh first" + [[ -f "${SYSTEMD_UNIT_DIR:-/etc/systemd/system}/$unit" ]] || { + err "missing systemd template ${SYSTEMD_UNIT_DIR:-/etc/systemd/system}/$unit - run guard-lib's install.sh first" exit 1 } done @@ -27,8 +30,8 @@ validate_requirements() { } # A live transaction would race every chattr and umount below. - if [[ -e /var/lib/pacman/db.lck ]]; then - err "/var/lib/pacman/db.lck exists - a pacman transaction is in flight" + if [[ -e "${PACMAN_DB_LCK:-/var/lib/pacman/db.lck}" ]]; then + err "${PACMAN_DB_LCK:-/var/lib/pacman/db.lck} exists - a pacman transaction is in flight" exit 1 fi } diff --git a/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_rollback.sh b/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_rollback.sh index 5cfc66d8..d7440490 100755 --- a/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_rollback.sh +++ b/linux_configuration/scripts/single_use/fixes/lib/hosts_guard_rollback.sh @@ -122,7 +122,9 @@ do_rollback() { # `chattr +i` with no collapse (it relies on running before the mount at # boot), so it fails silently here unless the mount is dropped first. So: # collapse, enforce, then let the bind-mount unit rebuild the ro layer. - collapse_mounts /etc/hosts + # HOSTS_FILE is a seam for the tests; production always passes the real + # path, which is the file this whole rollback exists to re-protect. + collapse_mounts "${HOSTS_FILE:-/etc/hosts}" local svc for svc in hosts-guard.service nsswitch-guard.service resolved-guard.service; do diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/hosts_guard_harness.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/hosts_guard_harness.sh new file mode 100755 index 00000000..3d954e01 --- /dev/null +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/hosts_guard_harness.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# lib/tests/hosts_guard_harness.sh — shared setup for the hosts-guard +# migration lib tests (hosts_guard_migrate.sh, hosts_guard_rollback.sh). +# +# Sourced, not executed. Builds on lib_test_core.sh and adds what both libs +# read from their entry script, migrate_hosts_guard_to_guard_lib.sh: +# +# * the path globals (GUARDCTL, TARGETS_DIR, STATE_DIR, HOOKS_DIR, +# PLUGIN_INSTALL_DIR, PLUGIN_SRC_DIR), all pointed into the tmpdir; +# * the LEGACY_HOOKS / LEGACY_UNITS / INSTANCES tables, verbatim; +# * msg/note/warn/err, `run`, instance_spec and instance_registered, +# redefined verbatim -- they live in the entry script, not in any lib; +# * the real log_*/has_cmd helpers, via scripts/lib/common.sh. +# +# The entry script marks most of these `readonly`, but it is never sourced +# here: this harness owns its own copies, so nothing is being overwritten. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXES_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${FIXES_DIR}/../../../.." && pwd)" + +# shellcheck source=lib_test_core.sh +. "${SCRIPT_DIR}/lib_test_core.sh" + +# shellcheck source=../../../../lib/common.sh +. "${REPO_ROOT}/linux_configuration/scripts/lib/common.sh" + +# --- globals owned by migrate_hosts_guard_to_guard_lib.sh ------------------- + +# Read by the libs under test, never by this harness, so static analysis +# cannot see the use; export makes the intent explicit. +export SCRIPT_NAME="migrate_hosts_guard_to_guard_lib.sh" +export GUARDCTL="${TEST_TMPDIR}/guardctl" +export TARGETS_DIR="${TEST_TMPDIR}/guard-lib/targets" +export STATE_DIR="${TEST_TMPDIR}/guard-lib-migration" +export HOOKS_DIR="${TEST_TMPDIR}/pacman.d/hooks" +export PLUGIN_INSTALL_DIR="${TEST_TMPDIR}/guard-lib-plugins" +export PLUGIN_SRC_DIR="${TEST_TMPDIR}/plugin_src" +# The two seams added to hosts_guard_migrate.sh, plus the one in +# hosts_guard_rollback.sh, so validate_requirements and do_rollback can be +# driven without a real /etc or a real pacman lock. +export SYSTEMD_UNIT_DIR="${TEST_TMPDIR}/systemd_units" +export PACMAN_DB_LCK="${TEST_TMPDIR}/pacman_db.lck" +export HOSTS_FILE="${TEST_TMPDIR}/etc_hosts" +export DRY_RUN=0 + +export RED='' +export GREEN='' +export YELLOW='' +export BLUE='' +export NC='' + +# Verbatim from the entry script. +readonly LEGACY_HOOKS=( + "10-unlock-etc-hosts.hook" + "90-relock-etc-hosts.hook" +) +readonly LEGACY_UNITS=( + "hosts-guard.path" + "hosts-guard.service" + "hosts-bind-mount.service" + "nsswitch-guard.path" + "nsswitch-guard.service" + "resolved-guard.path" + "resolved-guard.service" +) +readonly INSTANCES=(hosts nsswitch resolved) + +# The three tables above are read by the LIBS under test, never by this file, +# so shellcheck cannot see the use from here and flags each as unused. Bash +# cannot export an array, so the honest way to record the use is a helper the +# tests actually call -- it both silences the warning truthfully and gives the +# assertions a readable way to ask what the tables contain. +_t_table() { # + case "$1" in + hooks) printf '%s\n' "${LEGACY_HOOKS[@]}" ;; + units) printf '%s\n' "${LEGACY_UNITS[@]}" ;; + instances) printf '%s\n' "${INSTANCES[@]}" ;; + *) return 1 ;; + esac +} + +msg() { printf "${GREEN}[✓]${NC} %s\n" "$*"; } +note() { printf "${BLUE}[i]${NC} %s\n" "$*"; } +warn() { printf "${YELLOW}[!]${NC} %s\n" "$*"; } +err() { printf "${RED}[✗]${NC} %s\n" "$*" >&2; } + +run() { + if ((DRY_RUN == 1)); then + printf "${YELLOW}DRY-RUN:${NC} %s\n" "$*" + return 0 + fi + "$@" +} + +# instance_spec, with the three real targets redirected into the tmpdir so a +# migration can be driven end to end without touching /etc. +instance_spec() { # -> echoes "target|bind|plugin|also_watch" + case "$1" in + hosts) echo "${HOSTS_FILE}|yes||" ;; + nsswitch) echo "${TEST_TMPDIR}/nsswitch.conf|no|nsswitch-plugin.sh|" ;; + resolved) + echo "${TEST_TMPDIR}/resolved.conf|no|resolved-plugin.sh|${TEST_TMPDIR}/resolved.conf.d" + ;; + *) return 1 ;; + esac +} + +instance_registered() { [[ -f "$TARGETS_DIR/$1.conf" ]]; } + +_hosts_guard_default_stubs() { + local tool + for tool in systemctl chattr mountpoint umount install; do + _t_stub "$tool" 'exit 0' + done + # guardctl is addressed by absolute path, not through PATH, so it is + # written directly rather than stubbed. + cat >"${GUARDCTL}" <<'GUARDCTL_STUB' +#!/usr/bin/env bash +printf 'guardctl %s\n' "$*" >>"${LIB_TEST_DEV}/calls" +exit 0 +GUARDCTL_STUB + chmod +x "${GUARDCTL}" +} + +# hosts_guard_reset — start a test group from "nothing has happened yet". +hosts_guard_reset() { + _t_reset_calls + DRY_RUN=0 + export DRY_RUN + rm -rf "${TARGETS_DIR}" "${STATE_DIR}" "${HOOKS_DIR}" "${PLUGIN_INSTALL_DIR}" \ + "${PLUGIN_SRC_DIR}" "${SYSTEMD_UNIT_DIR}" + mkdir -p "${TARGETS_DIR}" "${HOOKS_DIR}" "${PLUGIN_SRC_DIR}" "${SYSTEMD_UNIT_DIR}" + rm -f "${PACMAN_DB_LCK}" + printf '127.0.0.1 localhost\n' >"${HOSTS_FILE}" + _t_full_path + _hosts_guard_default_stubs +} + +# _t_templates — create the three systemd templates validate_requirements +# insists on, so a test can reach the checks that come after them. +_t_templates() { + local unit + for unit in guard-file@.path guard-file@.service guard-bind-mount@.service; do + : >"${SYSTEMD_UNIT_DIR}/${unit}" + done +} + +# _t_register NAME... — mark instances as already registered with guard-lib. +_t_register() { + local name + for name in "$@"; do + : >"${TARGETS_DIR}/${name}.conf" + done +} + +hosts_guard_reset diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_migrate.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_migrate.sh new file mode 100755 index 00000000..d9d6503a --- /dev/null +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_migrate.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# lib/tests/test_hosts_guard_migrate.sh — tests for hosts_guard_migrate.sh. +# +# Covers validate_requirements' four refusals, plugin installation, the +# per-instance migration and its skips, the mount collapse loop, hook +# retirement, and do_migrate's refusal to retire the legacy hooks when +# nothing actually registered. +# +# Calls go through _t_run rather than `out="$(...)"`: command substitution +# forks a subshell, and kcov does not register a lib whose first execution +# happens inside one. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=hosts_guard_harness.sh +. "${SCRIPT_DIR}/hosts_guard_harness.sh" + +# shellcheck source=../hosts_guard_migrate.sh +. "${FIXES_DIR}/lib/hosts_guard_migrate.sh" + +printf '\n-- validate_requirements --\n' + +# Each refusal calls `exit 1`, so every case runs in a subshell. + +# Case 1: guardctl missing entirely. +hosts_guard_reset +rm -f "${GUARDCTL}" +rc=0 +(validate_requirements >"${TEST_TMPDIR}/o" 2>&1) || rc=$? +out="$(cat "${TEST_TMPDIR}/o")" +_t_eq "1" "$rc" "validate_requirements: exits 1 without guardctl" +_t_contains "$out" "guardctl not found" "validate_requirements: names the missing binary" + +# Case 2: guardctl present but the systemd templates are not. +hosts_guard_reset +rc=0 +(validate_requirements >"${TEST_TMPDIR}/o" 2>&1) || rc=$? +out="$(cat "${TEST_TMPDIR}/o")" +_t_eq "1" "$rc" "validate_requirements: exits 1 without the systemd templates" +_t_contains "$out" "missing systemd template" "validate_requirements: names the template" + +# Case 3: templates present but the plugin sources are not. +hosts_guard_reset +_t_templates +rm -rf "${PLUGIN_SRC_DIR}" +rc=0 +(validate_requirements >"${TEST_TMPDIR}/o" 2>&1) || rc=$? +out="$(cat "${TEST_TMPDIR}/o")" +_t_eq "1" "$rc" "validate_requirements: exits 1 without plugin sources" +_t_contains "$out" "plugin sources not found" "validate_requirements: names the source dir" + +# Case 4: a pacman transaction is in flight -- refuse, because every chattr +# and umount below would race it. +hosts_guard_reset +_t_templates +: >"${PACMAN_DB_LCK}" +rc=0 +(validate_requirements >"${TEST_TMPDIR}/o" 2>&1) || rc=$? +out="$(cat "${TEST_TMPDIR}/o")" +_t_eq "1" "$rc" "validate_requirements: exits 1 while pacman holds the lock" +_t_contains "$out" "a pacman transaction is in flight" \ + "validate_requirements: explains the refusal" + +# Case 5: everything in place -> passes. +hosts_guard_reset +_t_templates +rc=0 +(validate_requirements >"${TEST_TMPDIR}/o" 2>&1) || rc=$? +_t_eq "0" "$rc" "validate_requirements: passes when every precondition holds" + +printf '\n-- install_plugins --\n' + +# Case 6: the plugin sources are copied in, and only the .sh ones. +hosts_guard_reset +printf 'x\n' >"${PLUGIN_SRC_DIR}/nsswitch-plugin.sh" +printf 'x\n' >"${PLUGIN_SRC_DIR}/resolved-plugin.sh" +printf 'x\n' >"${PLUGIN_SRC_DIR}/README.md" +_t_run install_plugins +_t_contains "$(_t_calls)" "install -m 755" "install_plugins: installs mode 755" +_t_contains "$(_t_calls)" "nsswitch-plugin.sh" "install_plugins: installs the nsswitch plugin" +_t_lacks "$(_t_calls)" "README.md" "install_plugins: ignores non-.sh files" +_t_contains "$out" "plugins installed to" "install_plugins: reports where they went" + +# Case 7: an empty source directory is not an error -- the glob guard is what +# stops `*.sh` being passed through literally. +hosts_guard_reset +_t_run install_plugins +_t_eq "0" "$?" "install_plugins: survives an empty plugin source dir" +_t_lacks "$(_t_calls)" '*.sh' "install_plugins: does not install the unmatched glob" + +printf '\n-- collapse_mounts --\n' + +# Case 8: not a mountpoint -> the loop never runs. +hosts_guard_reset +_t_stub mountpoint 'exit 1' +_t_run collapse_mounts "${HOSTS_FILE}" +_t_eq "0" "$?" "collapse_mounts: returns 0 when nothing is mounted" +_t_lacks "$(_t_calls)" "umount" "collapse_mounts: does not unmount a plain file" + +# Case 9: a stack that clears after a few layers. The stub reports mounted +# until umount has been called three times, which is what a real stacked +# bind looks like on the way down. +hosts_guard_reset +: >"${TEST_TMPDIR}/umount_count" +_t_stub_stdin mountpoint <<'STUB' +count=$(wc -l <"${LIB_TEST_DEV}/../umount_count") +[[ $count -ge 3 ]] && exit 1 +exit 0 +STUB +# _t_stub already records the invocation into $DEV/calls, so this body only +# has to advance the counter mountpoint reads. +_t_stub_stdin umount <<'STUB' +echo x >>"${LIB_TEST_DEV}/../umount_count" +exit 0 +STUB +_t_run collapse_mounts "${HOSTS_FILE}" +_t_eq "3" "$(grep -c umount "${TEST_TMPDIR}/device/calls")" \ + "collapse_mounts: unmounts every stacked layer and then stops" + +# Case 10: umount fails -> break rather than spin. +hosts_guard_reset +_t_stub mountpoint 'exit 0' +_t_stub umount 'exit 1' +_t_run collapse_mounts "${HOSTS_FILE}" +_t_eq "0" "$?" "collapse_mounts: returns 0 when umount fails" +_t_eq "1" "$(grep -c umount "${TEST_TMPDIR}/device/calls")" \ + "collapse_mounts: gives up after the first failed umount" + +# Case 11: a mount that never clears is bounded at 20 attempts, not infinite. +hosts_guard_reset +_t_stub mountpoint 'exit 0' +_t_stub umount 'exit 0' +_t_run collapse_mounts "${HOSTS_FILE}" +_t_eq "21" "$(grep -c umount "${TEST_TMPDIR}/device/calls")" \ + "collapse_mounts: stops after 21 umounts rather than looping forever" + +# Case 12: under --dry-run nothing is really unmounted, so the loop reports +# once instead of hitting the 20-iteration cap. +hosts_guard_reset +DRY_RUN=1 +_t_stub mountpoint 'exit 0' +_t_run collapse_mounts "${HOSTS_FILE}" +_t_contains "$out" "DRY-RUN" "collapse_mounts: announces the intent under dry-run" +_t_eq "1" "$(grep -c 'DRY-RUN' <<<"$out")" \ + "collapse_mounts: reports the intent once, not twenty times" + +printf '\nhosts_guard_migrate (checks): %d passed, %d failed\n' "$PASS" "$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_migrate_flow.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_migrate_flow.sh new file mode 100755 index 00000000..13de9d28 --- /dev/null +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_migrate_flow.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# lib/tests/test_hosts_guard_migrate_flow.sh — tests for the migration flow +# itself: migrate_instance, stop_legacy_units_for, retire_legacy_hooks and +# do_migrate. +# +# Split from test_hosts_guard_migrate.sh to hold every file under the +# 250-line cap. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=hosts_guard_harness.sh +. "${SCRIPT_DIR}/hosts_guard_harness.sh" + +# shellcheck source=../hosts_guard_migrate.sh +. "${FIXES_DIR}/lib/hosts_guard_migrate.sh" + +# shellcheck source=../hosts_guard_rollback.sh +. "${FIXES_DIR}/lib/hosts_guard_rollback.sh" + +printf '\n-- stop_legacy_units_for --\n' + +# Case 1: only the units whose name matches the instance prefix are touched. +hosts_guard_reset +_t_run stop_legacy_units_for hosts +calls="$(_t_calls)" +_t_contains "$calls" "disable --now hosts-guard.path" \ + "stop_legacy_units_for: disables the matching path unit" +_t_contains "$calls" "disable --now hosts-bind-mount.service" \ + "stop_legacy_units_for: disables the matching bind-mount unit" +_t_lacks "$calls" "nsswitch-guard" \ + "stop_legacy_units_for: leaves another instance's units alone" + +# Case 2: a unit systemd does not know about is skipped, not disabled. +hosts_guard_reset +_t_stub_stdin systemctl <<'STUB' +[[ $1 == list-unit-files ]] && exit 1 +exit 0 +STUB +_t_run stop_legacy_units_for hosts +_t_lacks "$(_t_calls)" "disable --now" \ + "stop_legacy_units_for: skips units systemd does not have" + +printf '\n-- migrate_instance --\n' + +# Case 3: an already-registered instance is skipped before anything is done. +hosts_guard_reset +_t_register hosts +_t_run migrate_instance hosts +_t_contains "$out" "already registered - skipping" \ + "migrate_instance: skips an instance guard-lib already owns" +_t_lacks "$(_t_calls)" "guardctl" "migrate_instance: does not call guardctl when skipping" + +# Case 4: a target that does not exist is warned about, not migrated. +hosts_guard_reset +rm -f "${HOSTS_FILE}" +_t_run migrate_instance hosts +_t_contains "$out" "does not exist - skipping" \ + "migrate_instance: warns about a missing target" +_t_lacks "$(_t_calls)" "guardctl" "migrate_instance: does not migrate a missing target" + +# Case 5: the hosts instance -- bind-mounted, no plugin. +hosts_guard_reset +_t_run migrate_instance hosts +calls="$(_t_calls)" +_t_contains "$calls" "guardctl file-guard install hosts" \ + "migrate_instance: installs the hosts instance" +_t_contains "$calls" "--bind-mount" "migrate_instance: passes --bind-mount for hosts" +_t_lacks "$calls" "--plugin" "migrate_instance: passes no plugin for hosts" +_t_contains "$calls" "chattr -i" "migrate_instance: clears the immutable flag first" + +# Case 6: the resolved instance -- a plugin AND an --also-watch directory. +hosts_guard_reset +printf 'x\n' >"${TEST_TMPDIR}/resolved.conf" +_t_run migrate_instance resolved +calls="$(_t_calls)" +_t_contains "$calls" "--plugin" "migrate_instance: passes the plugin for resolved" +_t_contains "$calls" "resolved-plugin.sh" "migrate_instance: names the right plugin" +_t_contains "$calls" "--also-watch" "migrate_instance: passes the also-watch dir" +_t_lacks "$calls" "--bind-mount" "migrate_instance: does not bind-mount resolved" + +printf '\n-- retire_legacy_hooks --\n' + +# Case 7: no hooks left -> says so, removes nothing. +hosts_guard_reset +_t_run retire_legacy_hooks +_t_contains "$out" "already retired" "retire_legacy_hooks: reports nothing to do" + +# Case 8: both hooks present -> both removed. +hosts_guard_reset +: >"${HOOKS_DIR}/10-unlock-etc-hosts.hook" +: >"${HOOKS_DIR}/90-relock-etc-hosts.hook" +_t_run retire_legacy_hooks +_t_eq "0" "$?" "retire_legacy_hooks: returns 0 after retiring" +_t_contains "$out" "retired 10-unlock-etc-hosts.hook" \ + "retire_legacy_hooks: names the unlock hook" +_t_contains "$out" "retired 90-relock-etc-hosts.hook" \ + "retire_legacy_hooks: names the relock hook" +_t_lacks "$out" "already retired" "retire_legacy_hooks: does not also claim there was nothing to do" + +printf '\n-- do_migrate --\n' + +# Case 9: nothing registers -> REFUSE to retire the legacy hooks. Retiring +# them after a failed migration would leave the files with no pacman unlock +# hook at all, and the next transaction would fight chattr +i. +hosts_guard_reset +_t_templates +: >"${HOOKS_DIR}/10-unlock-etc-hosts.hook" +cat >"${GUARDCTL}" <<'STUB' +#!/usr/bin/env bash +printf 'guardctl %s\n' "$*" >>"${LIB_TEST_DEV}/calls" +exit 0 +STUB +chmod +x "${GUARDCTL}" +rc=0 +(do_migrate >"${TEST_TMPDIR}/o" 2>&1) || rc=$? +out="$(cat "${TEST_TMPDIR}/o")" +_t_eq "1" "$rc" "do_migrate: exits 1 when no instance registered" +_t_contains "$out" "refusing to retire the legacy hooks" "do_migrate: explains the refusal" +_t_eq "yes" "$([[ -f "${HOOKS_DIR}/10-unlock-etc-hosts.hook" ]] && echo yes || echo no)" \ + "do_migrate: leaves the legacy hook in place after refusing" + +# Case 10: a successful migration -- guardctl registers each instance, so the +# hooks are retired and the summary counts them. +hosts_guard_reset +_t_templates +printf 'x\n' >"${TEST_TMPDIR}/nsswitch.conf" +printf 'x\n' >"${TEST_TMPDIR}/resolved.conf" +: >"${HOOKS_DIR}/10-unlock-etc-hosts.hook" +cat >"${GUARDCTL}" <<'STUB' +#!/usr/bin/env bash +printf 'guardctl %s\n' "$*" >>"${LIB_TEST_DEV}/calls" +# Register the instance, the way the real guardctl would. +if [[ $2 == install ]]; then + : >"${LIB_TEST_TARGETS}/$3.conf" +fi +exit 0 +STUB +chmod +x "${GUARDCTL}" +LIB_TEST_TARGETS="${TARGETS_DIR}" +export LIB_TEST_TARGETS +_t_run do_migrate +_t_contains "$out" "migration complete (3 instance(s) registered)" \ + "do_migrate: counts every registered instance" +_t_eq "no" "$([[ -f "${HOOKS_DIR}/10-unlock-etc-hosts.hook" ]] && echo yes || echo no)" \ + "do_migrate: retires the legacy hooks once guard-lib owns something" + +# Case 11: under --dry-run nothing registers, but the refusal must NOT fire -- +# a dry run is expected to register nothing. +hosts_guard_reset +_t_templates +DRY_RUN=1 +_t_run do_migrate +_t_eq "0" "$?" "do_migrate: dry-run does not trip the no-registration refusal" +_t_contains "$out" "DRY-RUN" "do_migrate: dry-run announces its intent" + +printf '\nhosts_guard_migrate (flow): %d passed, %d failed\n' "$PASS" "$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_rollback.sh b/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_rollback.sh new file mode 100755 index 00000000..e3e5f475 --- /dev/null +++ b/linux_configuration/scripts/single_use/fixes/lib/tests/test_hosts_guard_rollback.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# lib/tests/test_hosts_guard_rollback.sh — tests for hosts_guard_rollback.sh: +# show_status, save_rollback_state and do_rollback. +# +# Calls go through _t_run rather than `out="$(...)"`: command substitution +# forks a subshell, and kcov does not register a lib whose first execution +# happens inside one. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=hosts_guard_harness.sh +. "${SCRIPT_DIR}/hosts_guard_harness.sh" + +# shellcheck source=../hosts_guard_migrate.sh +. "${FIXES_DIR}/lib/hosts_guard_migrate.sh" + +# shellcheck source=../hosts_guard_rollback.sh +. "${FIXES_DIR}/lib/hosts_guard_rollback.sh" + +printf '\n-- show_status --\n' + +# Case 1: nothing registered, hooks gone, units absent. +hosts_guard_reset +_t_stub systemctl 'exit 1' +_t_run show_status +_t_contains "$out" "hosts NOT registered" "show_status: reports an unregistered instance" +_t_contains "$out" "10-unlock-etc-hosts.hook retired" "show_status: reports a retired hook" +_t_contains "$out" "hosts-guard.path is absent" \ + "show_status: reports an absent unit rather than an empty state" + +# Case 2: registered instances, hooks still present, a unit still enabled. +# `systemctl is-enabled` PRINTS the state but EXITS 1 for anything not +# enabled, which is why the lib cannot use `|| echo absent`. +hosts_guard_reset +_t_register hosts nsswitch resolved +: >"${HOOKS_DIR}/10-unlock-etc-hosts.hook" +_t_stub_stdin systemctl <<'STUB' +[[ $1 == is-enabled ]] && { echo enabled; exit 0; } +exit 0 +STUB +_t_run show_status +_t_contains "$out" "hosts registered" "show_status: reports a registered instance" +_t_contains "$out" "10-unlock-etc-hosts.hook still present" \ + "show_status: warns about a surviving hook" +_t_contains "$out" "hosts-guard.path is enabled" "show_status: warns about an enabled unit" + +# Case 3: a unit that is disabled rather than absent -- is-enabled prints the +# state AND exits non-zero, and the lib must keep the printed word. +hosts_guard_reset +_t_stub_stdin systemctl <<'STUB' +[[ $1 == is-enabled ]] && { echo disabled; exit 1; } +exit 0 +STUB +_t_run show_status +_t_contains "$out" "hosts-guard.path is disabled" \ + "show_status: keeps the state systemd printed on a non-zero exit" +_t_lacks "$out" "hosts-guard.path is absent" \ + "show_status: does not overwrite a real state with 'absent'" + +printf '\n-- save_rollback_state --\n' + +# Case 4: hooks are backed up and unit states recorded. +hosts_guard_reset +: >"${HOOKS_DIR}/10-unlock-etc-hosts.hook" +_t_stub_stdin systemctl <<'STUB' +[[ $1 == is-enabled ]] && { echo enabled; exit 0; } +exit 0 +STUB +_t_run save_rollback_state +_t_contains "$out" "backed up 10-unlock-etc-hosts.hook" \ + "save_rollback_state: backs up a present hook" +_t_eq "yes" "$([[ -f "${STATE_DIR}/hooks/10-unlock-etc-hosts.hook" ]] && echo yes || echo no)" \ + "save_rollback_state: the backup lands in the state dir" +_t_contains "$(cat "${STATE_DIR}/units.state")" "hosts-guard.path=enabled" \ + "save_rollback_state: records the exact prior enablement" + +# Case 5: an existing backup is NOT overwritten -- the first capture is the +# pre-migration truth, and a second run must not clobber it. +hosts_guard_reset +mkdir -p "${STATE_DIR}/hooks" +printf 'ORIGINAL\n' >"${STATE_DIR}/hooks/10-unlock-etc-hosts.hook" +printf 'CHANGED\n' >"${HOOKS_DIR}/10-unlock-etc-hosts.hook" +_t_stub systemctl 'exit 0' +_t_run save_rollback_state +_t_eq "ORIGINAL" "$(cat "${STATE_DIR}/hooks/10-unlock-etc-hosts.hook")" \ + "save_rollback_state: keeps the first backup" + +# Case 6: units.state is likewise written once only. +hosts_guard_reset +mkdir -p "${STATE_DIR}" +printf 'PRESERVED\n' >"${STATE_DIR}/units.state" +_t_stub systemctl 'exit 0' +_t_run save_rollback_state +_t_eq "PRESERVED" "$(cat "${STATE_DIR}/units.state")" \ + "save_rollback_state: does not rewrite units.state" + +# Case 7: under --dry-run no unit state is recorded at all. +hosts_guard_reset +DRY_RUN=1 +_t_stub systemctl 'exit 0' +_t_run save_rollback_state +_t_eq "no" "$([[ -f "${STATE_DIR}/units.state" ]] && echo yes || echo no)" \ + "save_rollback_state: dry-run records no unit state" + +printf '\n-- do_rollback --\n' + +# Case 8: no state dir at all -> refuse. +hosts_guard_reset +rm -rf "${STATE_DIR}" +rc=0 +(do_rollback >"${TEST_TMPDIR}/o" 2>&1) || rc=$? +out="$(cat "${TEST_TMPDIR}/o")" +_t_eq "1" "$rc" "do_rollback: exits 1 with no rollback state" +_t_contains "$out" "nothing to roll back to" "do_rollback: explains the refusal" + +# Case 9: a full rollback -- uninstall keeps the canonical copy, hooks are +# restored, enabled units come back, and the legacy enforcement re-runs. +hosts_guard_reset +_t_register hosts +mkdir -p "${STATE_DIR}/hooks" +printf 'HOOK\n' >"${STATE_DIR}/hooks/10-unlock-etc-hosts.hook" +printf 'hosts-guard.path=enabled\nnsswitch-guard.path=disabled\n' >"${STATE_DIR}/units.state" +_t_run do_rollback +calls="$(_t_calls)" +_t_contains "$calls" "file-guard uninstall hosts --keep-canonical" \ + "do_rollback: keeps the canonical copy, so a rollback is not data loss" +_t_eq "HOOK" "$(cat "${HOOKS_DIR}/10-unlock-etc-hosts.hook")" \ + "do_rollback: restores the backed-up hook" +_t_contains "$calls" "enable --now hosts-guard.path" \ + "do_rollback: re-enables a unit that was enabled before" +_t_lacks "$calls" "enable --now nsswitch-guard.path" \ + "do_rollback: leaves a unit that was disabled before" +_t_contains "$calls" "start hosts-guard.service" \ + "do_rollback: re-runs the legacy enforcement" +_t_contains "$calls" "restart hosts-bind-mount.service" \ + "do_rollback: rebuilds the read-only bind mount last" +_t_contains "$out" "rollback complete" "do_rollback: reports completion" + +# Case 10: no units.state -> warn and leave the units alone rather than +# guessing which of them ought to be enabled. +hosts_guard_reset +mkdir -p "${STATE_DIR}" +_t_run do_rollback +_t_contains "$out" "no units.state recorded" "do_rollback: warns when it cannot restore units" +_t_lacks "$(_t_calls)" "enable --now" "do_rollback: enables nothing without a record" + +# Case 11: an instance that is not registered is not uninstalled. +hosts_guard_reset +mkdir -p "${STATE_DIR}" +_t_run do_rollback +_t_lacks "$(_t_calls)" "file-guard uninstall" \ + "do_rollback: does not uninstall an unregistered instance" + +# Case 12: a unit systemd knows nothing about is recorded as "absent" +# rather than as an empty string, which is the fallback arm inside the +# state-writing loop. +hosts_guard_reset +_t_stub_stdin systemctl <<'STUB' +[[ $1 == is-enabled ]] && exit 1 +exit 0 +STUB +_t_run save_rollback_state +_t_contains "$(cat "${STATE_DIR}/units.state")" "hosts-guard.path=absent" \ + "save_rollback_state: records an unknown unit as absent" +_t_eq "0" "$(grep -c '=$' "${STATE_DIR}/units.state")" \ + "save_rollback_state: never writes a line with an empty state" + +# Case 13: re-enabling a unit can fail, and the rollback must warn and carry +# on rather than abort with units half-restored. +hosts_guard_reset +mkdir -p "${STATE_DIR}" +printf 'hosts-guard.path=enabled\nresolved-guard.path=enabled\n' >"${STATE_DIR}/units.state" +_t_stub_stdin systemctl <<'STUB' +[[ $1 == enable ]] && exit 1 +exit 0 +STUB +_t_run do_rollback +_t_contains "$out" "could not re-enable hosts-guard.path" \ + "do_rollback: warns when a unit will not re-enable" +_t_contains "$out" "could not re-enable resolved-guard.path" \ + "do_rollback: keeps going after a failed re-enable" +_t_contains "$out" "rollback complete" "do_rollback: still completes" + +printf '\nhosts_guard_rollback: %d passed, %d failed\n' "$PASS" "$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/meta/scripts/lib/shell_coverage_lines.py b/meta/scripts/lib/shell_coverage_lines.py index de78d6b3..efde4e34 100755 --- a/meta/scripts/lib/shell_coverage_lines.py +++ b/meta/scripts/lib/shell_coverage_lines.py @@ -75,6 +75,18 @@ def traced_paths(trace_dir: Path, subject: str) -> list[Path]: # bare `}` closing a function carries no operator. _CLOSING_BRACE_OP = re.compile(r"^\s*\}\s*(\||>>?|<|&>)") +# A loop terminator carrying a redirect: `done >file`, `done "$1"` line reports hits=0 in the same run. +# +# The redirect is REQUIRED by the pattern. A bare `done` closing an ordinary +# loop is attributed normally and must stay in the denominator, so matching +# it here would hide real uncovered code. +_LOOP_END_OP = re.compile(r"^\s*done\s*(\||>>?|<|&>)") + # An assignment whose value opens a multi-line array literal, e.g. # `local -a candidates=(` or `FILES=(`. bash reports the WHOLE assignment at # this opening line and never emits the element lines or the closing paren, so @@ -103,6 +115,8 @@ def continuation_lines(source: Path) -> set[int]: opening line of such a construct is a statement. * a line holding just a group's closing brace and an operator (``} |``, ``} >>file``). See ``_CLOSING_BRACE_OP``. + * a loop terminator carrying a redirect (``done >file``, ``done set[int]: in_array = False elif starts_open: in_array = True - if quote is None and not in_array and _CLOSING_BRACE_OP.match(raw): + if ( + quote is None + and not in_array + and (_CLOSING_BRACE_OP.match(raw) or _LOOP_END_OP.match(raw)) + ): inside.add(number) return inside diff --git a/meta/shell-coverage-allowlist.txt b/meta/shell-coverage-allowlist.txt index 7de7c68f..598097ba 100644 --- a/meta/shell-coverage-allowlist.txt +++ b/meta/shell-coverage-allowlist.txt @@ -75,8 +75,6 @@ linux_configuration/scripts/single_use/features/lib/wg_keys.sh linux_configuration/scripts/single_use/features/lib/wg_sshd.sh linux_configuration/scripts/single_use/fixes/lib/arch_sysctl.sh linux_configuration/scripts/single_use/fixes/lib/bt_adapter.sh -linux_configuration/scripts/single_use/fixes/lib/hosts_guard_migrate.sh -linux_configuration/scripts/single_use/fixes/lib/hosts_guard_rollback.sh linux_configuration/scripts/single_use/fixes/lib/nvidia_config.sh linux_configuration/scripts/single_use/fixes/lib/pacman_hook_stall_setup.sh linux_configuration/scripts/single_use/fixes/lib/ubuntu_perf_fixes.sh