mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
Split test_security_hardening.sh; finish the mutation-driver split
Two files under the cap. test_security_hardening.sh (423) keeps its filename -- shell-tests.yml runs the suite by name -- and its five sections move into three sourced libs. They are top-level blocks, not functions, so each is wrapped and called in the original order; the counters and LIVE_SECTION stay in the entry, which sourcing shares. Verified by diffing a full run against the same suite from a detached worktree at HEAD: byte-identical output, same exit status, still 18/1/5 on this host. The one failure is pre-existing. Linting a lib alone turns a bare LIVE_SECTION=1 into SC2034, since its only reader is test_result in the entry. A live_section() setter makes the write a real reference, so no suppression was needed. The mutation-driver split was already in the tree from the previous session and did not run: the operators had moved to _session_autopsy_mutations.py but the driver still named Mutation without importing it, so mypy and pylint both reported undefined-variable. Restored the import; the script now reports all 12 tests killed. block_compulsive_opening.sh (705) is deliberately untouched and now says so in the contract. install_all copies the running script to /usr/local/bin, so an entry sourcing lib/ would resolve SCRIPT_DIR there, find nothing, and leave beeper, signal-desktop and discord unlaunchable with a failing pacman hook. Splitting it means teaching the installer to copy the libs -- a refactor, not a line-count split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1506371976
commit
656375275b
@ -0,0 +1,24 @@
|
||||
{
|
||||
"title": "Split mutate_session_autopsy_hooks.py and test_security_hardening.sh",
|
||||
"objective": "Bring two files under the repo-wide 250-line cap: meta/scripts/mutate_session_autopsy_hooks.py (253, over by 3) by moving its eight mutation operators into a sibling module, and linux_configuration/tests/test_security_hardening.sh (423) by moving its five test sections into three sourced libs -- neither changing what any check asserts or reports.",
|
||||
"acceptance_criteria": [
|
||||
"Both mutate_session_autopsy_hooks.py and _session_autopsy_mutations.py are at most 250 lines.",
|
||||
"ruff, mypy and pylint all pass on both files with no suppression added and no per-file-ignore introduced.",
|
||||
"Running the script produces output byte-identical to the same run at HEAD: the same eight mutations in the same order, each killing the same named tests, ending in 'PASS: all 12 tests killed by at least one mutation'.",
|
||||
"The deployed hooks under ~/.claude/hooks are not modified, which the script guarantees by copying before mutating.",
|
||||
"No test file is edited: nothing imports or patches this module, so the split needs no test change at all.",
|
||||
"test_security_hardening.sh and all three new libs are at most 250 lines, and the entry keeps its filename because .github/workflows/shell-tests.yml runs the suite by filename.",
|
||||
"The suite's output is byte-identical to a run from a detached worktree at HEAD once the repo path prefix is normalised, with the same exit status and the same Passed 18 / Failed 1 / Skipped 5 tally on this host.",
|
||||
"Each new shell lib passes shellcheck ALONE, without -x, which is the check that decides whether the seam is real; the SC2034 the seam exposes is resolved with a live_section() setter rather than a disable comment.",
|
||||
"shfmt reports no diff on the entry or any new lib."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Changing any mutation operator, the set of mutations, or the tests they target.",
|
||||
"Adding meta/scripts/__init__.py. The sibling _schema_validation.py already establishes the convention here: a #!/usr/bin/env python3 shebang marks the file as a script and satisfies ruff's INP001 without turning meta/scripts into a package.",
|
||||
"Changing how the pre-commit hook invokes the script.",
|
||||
"Fixing the one pre-existing failure test_security_hardening.sh reports on this host.",
|
||||
"Splitting block_compulsive_opening.sh. Its install_all copies the running script to /usr/local/bin, so an entry+lib shape would ship an entry whose SCRIPT_DIR resolves to a directory with no lib/, breaking three daily-use apps and the pacman rewrap hook.",
|
||||
"Wiring the file-length pre-commit gate, which must land only once --all already exits 0."
|
||||
],
|
||||
"verifier": "python3 meta/scripts/mutate_session_autopsy_hooks.py diffed against the same command run from a detached worktree at HEAD (expects identical output), plus pre-commit run --files on both files covering ruff, mypy, pylint and bandit; and bash linux_configuration/tests/test_security_hardening.sh diffed against the same suite run from a detached worktree at HEAD, plus shellcheck on each lib alone, shellcheck -x on the entry, and shfmt -d on all four shell files."
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
{
|
||||
"intent": "Bring mutate_session_autopsy_hooks.py under the 250-line cap by extracting its mutation operators, leaving the mutation results unchanged.",
|
||||
"scope": [
|
||||
"meta/scripts/mutate_session_autopsy_hooks.py (253 -> 178 lines)",
|
||||
"New: meta/scripts/_session_autopsy_mutations.py (100 lines)",
|
||||
"Non-goal: changing any mutation, or the hooks and tests they exercise"
|
||||
],
|
||||
"changes": [
|
||||
"Moved the eight mutation operators (_gut, _drop_transcript_guard, _widen_count, _drop_numeric_guard, _drop_readable_guard, _always_announce, _unguarded_and_always_announce), the Mutation NamedTuple, the MUTATIONS table and the _GUTTED constant into _session_autopsy_mutations.py.",
|
||||
"Promoted _END_HOOK/_START_HOOK to END_HOOK/START_HOOK and _MUTATIONS to MUTATIONS, since they now cross a module boundary, and declared __all__ for the four exported names.",
|
||||
"The driver keeps _TEST_FILE, _OUTCOME, and everything that touches the filesystem or runs pytest. The new module is pure text transforms, which makes the 'deployed hooks are never modified' guarantee easier to see.",
|
||||
"Gave the new module a #!/usr/bin/env python3 shebang, matching the sibling _schema_validation.py. That is what satisfies ruff's INP001 here -- ruff exempts files it recognises as scripts -- so no __init__.py and no per-file-ignore were needed."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "python3 meta/scripts/mutate_session_autopsy_hooks.py, diffed against the same run from a detached worktree at HEAD",
|
||||
"result": "pass",
|
||||
"evidence": "Byte-identical output. All eight mutations run in the same order and kill the same named tests (gut-end 2, gut-start 1, end-no-transcript-guard 4, start-count-ge-zero 1, start-no-numeric-guard 1, start-no-readable-guard 0, start-always-announce 3, start-unguarded-announce 5), ending in 'PASS: all 12 tests killed by at least one mutation'."
|
||||
},
|
||||
{
|
||||
"command": "pre-commit run --files on both files",
|
||||
"result": "pass",
|
||||
"evidence": "ruff, mypy, pylint and bandit all pass. Three real defects were caught and fixed rather than suppressed: the new module needed its own `import re`; the driver still annotates _apply with Mutation and so needed that name imported too (F821 from ruff, name-defined from mypy, E0602 from pylint -- three tools, one bug); and INP001 was answered with the sibling's shebang convention."
|
||||
},
|
||||
{
|
||||
"command": "bash ~/utils/scripts/check_file_length.sh",
|
||||
"result": "pass",
|
||||
"evidence": "driver 178, mutations 100. Neither appears in the violation list."
|
||||
},
|
||||
{
|
||||
"command": "grep for importers and patch targets",
|
||||
"result": "pass",
|
||||
"evidence": "Nothing in the repository imports this module and no test patches it; it is only a pre-commit entry point (.pre-commit-config.yaml:276). So no test file needed editing, and the decision-8 bound on test edits does not come into play."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"The bare `from _session_autopsy_mutations import ...` relies on Python putting the script's own directory on sys.path. That holds when the file is run as a script, which is the only way it is used -- verified by actually running it from the repository root, the same way .pre-commit-config.yaml invokes it. The sibling validate_evidence.py imports _schema_validation the same way.",
|
||||
"The first run after the split failed with NameError on `re`, caught only by executing the script -- ast.parse had reported the file as fine. A missing import in a moved block is invisible to a parse check.",
|
||||
"A PostToolUse formatter fired on the new module immediately after the `import re` edit; the import was re-checked with grep before proceeding, per the known trap."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the commit; the driver returns to its 253-line form and the sibling disappears.",
|
||||
"After rollback, run python3 meta/scripts/mutate_session_autopsy_hooks.py and confirm it still ends in 'PASS: all 12 tests killed by at least one mutation'."
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
{
|
||||
"intent": "Bring linux_configuration/tests/test_security_hardening.sh under the 250-line cap without changing a single assertion or its result, and finish the meta/scripts/mutate_session_autopsy_hooks.py split that a previous session left half-applied and broken in the working tree.",
|
||||
"scope": [
|
||||
"linux_configuration/tests/test_security_hardening.sh (423 -> 116 lines)",
|
||||
"linux_configuration/tests/lib/security_hosts_shutdown.sh (new)",
|
||||
"linux_configuration/tests/lib/security_pacman.sh (new)",
|
||||
"linux_configuration/tests/lib/security_apps.sh (new)",
|
||||
"meta/scripts/mutate_session_autopsy_hooks.py (253 -> 175 lines)",
|
||||
"meta/scripts/_session_autopsy_mutations.py (new, 99 lines)",
|
||||
"Non-goal: changing, weakening, skipping or deleting any test",
|
||||
"Non-goal: fixing the one pre-existing failure this suite reports on this host"
|
||||
],
|
||||
"changes": [
|
||||
"Moved the five top-level test sections into three sourced libs, each section wrapped in a function and invoked from the entry script in the original order, so execution order is unchanged.",
|
||||
"Kept the entry filename, since .github/workflows/shell-tests.yml runs this suite by filename.",
|
||||
"Counters (PASS/FAIL/SKIP) and LIVE_SECTION still live in the entry script; sourcing shares the shell, so the libs mutate them exactly as the former top-level blocks did.",
|
||||
"Added a live_section() setter in the entry script and routed the libs' LIVE_SECTION writes through it. Linted in isolation a bare assignment reads as SC2034, since its only reader (test_result) is in the entry file; the setter makes the write a real reference instead of a suppression.",
|
||||
"Finished the mutate_session_autopsy_hooks.py split found half-applied in the working tree: the mutation operators had been moved to _session_autopsy_mutations.py but the driver still referenced Mutation without importing it, so mypy and pylint both failed with undefined-variable and the file could not have been committed as left."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "bash -n on entry + all three libs",
|
||||
"result": "pass",
|
||||
"evidence": "OK test_security_hardening.sh; OK lib/security_apps.sh; OK lib/security_hosts_shutdown.sh; OK lib/security_pacman.sh"
|
||||
},
|
||||
{
|
||||
"command": "shellcheck lib/security_*.sh (each lib alone, no -x)",
|
||||
"result": "pass",
|
||||
"evidence": "LIBS CLEAN - no SC2034, no suppressions added"
|
||||
},
|
||||
{
|
||||
"command": "shellcheck -x test_security_hardening.sh",
|
||||
"result": "pass",
|
||||
"evidence": "ENTRY CLEAN"
|
||||
},
|
||||
{
|
||||
"command": "shfmt -d test_security_hardening.sh lib/security_*.sh",
|
||||
"result": "pass",
|
||||
"evidence": "SHFMT CLEAN. The pre-split entry was already shfmt-dirty; the new files are clean and were not made dirty."
|
||||
},
|
||||
{
|
||||
"command": "bash test_security_hardening.sh, diffed against the same suite run from a detached worktree at HEAD",
|
||||
"result": "pass",
|
||||
"evidence": "OUTPUT BYTE-IDENTICAL PRE/POST SPLIT after normalising the repo path prefix. Both runs: Passed 18 / Failed 1 / Skipped 5, both exit 1. The single failure is pre-existing and unrelated to this split."
|
||||
},
|
||||
{
|
||||
"command": "python3 meta/scripts/mutate_session_autopsy_hooks.py",
|
||||
"result": "pass",
|
||||
"evidence": "PASS: all 12 tests killed by at least one mutation. Ran end to end, so the bare 'from _session_autopsy_mutations import ...' resolves in the real invocation path."
|
||||
},
|
||||
{
|
||||
"command": "pre-commit run --files on both Python files",
|
||||
"result": "pass",
|
||||
"evidence": "ruff, ruff format, mypy, pylint, bandit and pytest-with-coverage all Passed after the missing Mutation import was restored; the mutation hook itself reports PASS."
|
||||
},
|
||||
{
|
||||
"command": "bash ~/utils/scripts/check_file_length.sh --all | grep -c 'over by'",
|
||||
"result": "pass",
|
||||
"evidence": "66 -> 62 violations; neither split file appears any more."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"The libs are only valid when sourced from the entry script, which owns the counters, colors and test_result(). Running a lib directly is not supported and never was a use case.",
|
||||
"A future section added to a lib must call live_section() rather than assigning LIVE_SECTION, or it reintroduces SC2034."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the commit; the entry script returns to a single 423-line file and the lib/ directory disappears.",
|
||||
"After rollback, run bash linux_configuration/tests/test_security_hardening.sh and confirm it still reports Passed 18 / Failed 1 / Skipped 5 on this host."
|
||||
]
|
||||
}
|
||||
119
linux_configuration/tests/lib/security_apps.sh
Executable file
119
linux_configuration/tests/lib/security_apps.sh
Executable file
@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
# Security hardening tests: compulsive-opening block + screen locker.
|
||||
# Sourced by test_security_hardening.sh; inherits its strict mode,
|
||||
# colors, counters (PASS/FAIL/SKIP), LIVE_SECTION and test_result().
|
||||
|
||||
sec_tests_compulsive_block() {
|
||||
# ==================================================================
|
||||
# COMPULSIVE BLOCK TESTS
|
||||
# ==================================================================
|
||||
echo "--- COMPULSIVE OPENING BLOCK ---"
|
||||
|
||||
compulsive_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh"
|
||||
|
||||
# Test 17: Auto-close timer configuration exists
|
||||
if [[ -f "$compulsive_file" ]]; then
|
||||
if grep -q "AUTO_CLOSE_TIMEOUT_MINUTES" "$compulsive_file"; then
|
||||
test_result "Auto-close timer configuration exists" "pass"
|
||||
else
|
||||
test_result "Auto-close timer configuration exists" "fail" "Variable not found"
|
||||
fi
|
||||
else
|
||||
test_result "Auto-close timer configuration exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 18: launch_with_timer function exists
|
||||
if [[ -f "$compulsive_file" ]]; then
|
||||
if grep -q "launch_with_timer" "$compulsive_file"; then
|
||||
test_result "launch_with_timer function exists" "pass"
|
||||
else
|
||||
test_result "launch_with_timer function exists" "fail" "Function not found"
|
||||
fi
|
||||
else
|
||||
test_result "launch_with_timer function exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 19: Compulsive block wrappers installed
|
||||
wrappers_ok=true
|
||||
for app in beeper signal-desktop discord; do
|
||||
if [[ -f "/usr/bin/$app" ]]; then
|
||||
if grep -q "block-compulsive-opening" "/usr/bin/$app" 2>/dev/null; then
|
||||
: # OK
|
||||
else
|
||||
wrappers_ok=false
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [[ "$wrappers_ok" == true ]]; then
|
||||
test_result "Compulsive block wrappers installed" "pass"
|
||||
else
|
||||
test_result "Compulsive block wrappers installed" "fail" "Some wrappers missing or incorrect"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
}
|
||||
|
||||
sec_tests_screen_locker() {
|
||||
# ==================================================================
|
||||
# SCREEN LOCKER TESTS
|
||||
# ==================================================================
|
||||
echo "--- SCREEN LOCKER ---"
|
||||
|
||||
screen_locker="$HOME/testsAndMisc/python_pkg/screen_locker/screen_lock.py"
|
||||
|
||||
# Test 20: Screen locker exists
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
test_result "Screen locker script exists" "pass"
|
||||
else
|
||||
test_result "Screen locker script exists" "skip" "Not found at expected location"
|
||||
fi
|
||||
|
||||
# Test 21: Running option removed
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
# Check that there's no "Running" button in ask_workout_type
|
||||
if grep -A 20 "def ask_workout_type" "$screen_locker" | grep -q '"Running"'; then
|
||||
test_result "Running workout option removed" "fail" "Still present in ask_workout_type"
|
||||
else
|
||||
test_result "Running workout option removed" "pass"
|
||||
fi
|
||||
else
|
||||
test_result "Running workout option removed" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 22: Table tennis minimum sets validation
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
if grep -q "MIN_TABLE_TENNIS_SETS" "$screen_locker"; then
|
||||
test_result "Table tennis minimum sets validation exists" "pass"
|
||||
else
|
||||
test_result "Table tennis minimum sets validation exists" "fail" "Constant not found"
|
||||
fi
|
||||
else
|
||||
test_result "Table tennis minimum sets validation exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 23: Table tennis verification question
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
if grep -q "ask_table_tennis_verification" "$screen_locker"; then
|
||||
test_result "Table tennis verification question exists" "pass"
|
||||
else
|
||||
test_result "Table tennis verification question exists" "fail" "Function not found"
|
||||
fi
|
||||
else
|
||||
test_result "Table tennis verification question exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 24: 60 second submit delay for table tennis
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
if grep -q "TABLE_TENNIS_SUBMIT_DELAY = 60" "$screen_locker"; then
|
||||
test_result "Table tennis 60-second submit delay" "pass"
|
||||
else
|
||||
test_result "Table tennis 60-second submit delay" "fail" "Constant not set to 60"
|
||||
fi
|
||||
else
|
||||
test_result "Table tennis 60-second submit delay" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
}
|
||||
160
linux_configuration/tests/lib/security_hosts_shutdown.sh
Executable file
160
linux_configuration/tests/lib/security_hosts_shutdown.sh
Executable file
@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
# Security hardening tests: hosts guard + shutdown schedule.
|
||||
# Sourced by test_security_hardening.sh; inherits its strict mode,
|
||||
# colors, counters (PASS/FAIL/SKIP), LIVE_SECTION and test_result().
|
||||
|
||||
sec_tests_hosts_guard() {
|
||||
# ==================================================================
|
||||
# HOSTS GUARD TESTS
|
||||
# ==================================================================
|
||||
live_section 1 # live-host checks begin
|
||||
echo "--- HOSTS GUARD ---"
|
||||
|
||||
# Test 1: /etc/hosts is immutable
|
||||
if [[ -f /etc/hosts ]]; then
|
||||
if lsattr /etc/hosts 2>/dev/null | grep -q '^....i'; then
|
||||
test_result "/etc/hosts is immutable" "pass"
|
||||
else
|
||||
test_result "/etc/hosts is immutable" "fail" "chattr +i not set"
|
||||
fi
|
||||
else
|
||||
test_result "/etc/hosts is immutable" "skip" "File not found"
|
||||
fi
|
||||
|
||||
# Test 2: the hosts file watcher is active
|
||||
# The 2026-07-17 guard-lib refactor replaced the standalone hosts-guard.path
|
||||
# with the templated guard-file@hosts.path. Accept either, so this passes on a
|
||||
# migrated host and on one still running the legacy unit. Checking only the old
|
||||
# name reported the protection as down when it was simply renamed.
|
||||
if systemctl is-active --quiet guard-file@hosts.path 2>/dev/null ||
|
||||
systemctl is-active --quiet hosts-guard.path 2>/dev/null; then
|
||||
test_result "hosts file watcher is active" "pass"
|
||||
else
|
||||
test_result "hosts file watcher is active" "fail" \
|
||||
"neither guard-file@hosts.path nor hosts-guard.path is running"
|
||||
fi
|
||||
|
||||
# Test 3: /etc/hosts is bind-mounted read-only
|
||||
# guard-bind-mount@hosts.service superseded hosts-bind-mount.service. The mount
|
||||
# itself is the thing that matters, so check that too — it is the real
|
||||
# invariant, independent of which unit established it.
|
||||
if systemctl is-active --quiet guard-bind-mount@hosts.service 2>/dev/null ||
|
||||
systemctl is-active --quiet hosts-bind-mount.service 2>/dev/null ||
|
||||
findmnt -no OPTIONS /etc/hosts 2>/dev/null | grep -q '\bro\b'; then
|
||||
test_result "/etc/hosts bind-mounted read-only" "pass"
|
||||
else
|
||||
test_result "/etc/hosts bind-mounted read-only" "fail" \
|
||||
"no bind-mount unit active and /etc/hosts is not mounted read-only"
|
||||
fi
|
||||
|
||||
# Test 4: Canonical hosts copy exists
|
||||
if [[ -f /usr/local/share/locked-hosts ]]; then
|
||||
test_result "Canonical hosts copy exists" "pass"
|
||||
else
|
||||
test_result "Canonical hosts copy exists" "fail" "Not found at /usr/local/share/locked-hosts"
|
||||
fi
|
||||
|
||||
# Test 5: the nsswitch watcher is active
|
||||
# guard-file@nsswitch.path superseded nsswitch-guard.path. The legacy unit is
|
||||
# left in a failed state on migrated hosts (it and the new watcher both
|
||||
# enforced the same file, so it retriggered itself into systemd's start
|
||||
# limit) — that is obsolete-unit noise, not a lapse in protection.
|
||||
if systemctl is-active --quiet guard-file@nsswitch.path 2>/dev/null ||
|
||||
systemctl is-active --quiet nsswitch-guard.path 2>/dev/null; then
|
||||
test_result "nsswitch watcher is active" "pass"
|
||||
else
|
||||
test_result "nsswitch watcher is active" "fail" \
|
||||
"neither guard-file@nsswitch.path nor nsswitch-guard.path is running"
|
||||
fi
|
||||
|
||||
# Test 6: /etc/nsswitch.conf is immutable (NEW)
|
||||
if [[ -f /etc/nsswitch.conf ]]; then
|
||||
if lsattr /etc/nsswitch.conf 2>/dev/null | grep -q '^....i'; then
|
||||
test_result "/etc/nsswitch.conf is immutable" "pass"
|
||||
else
|
||||
test_result "/etc/nsswitch.conf is immutable" "fail" "chattr +i not set"
|
||||
fi
|
||||
else
|
||||
test_result "/etc/nsswitch.conf is immutable" "skip" "File not found"
|
||||
fi
|
||||
|
||||
# Test 7: nsswitch.conf has correct hosts line
|
||||
if [[ -f /etc/nsswitch.conf ]]; then
|
||||
hosts_line=$(grep "^hosts:" /etc/nsswitch.conf 2>/dev/null || true)
|
||||
if echo "$hosts_line" | grep -q 'files.*dns\|files.*myhostname'; then
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "pass"
|
||||
elif [[ -z "$hosts_line" ]]; then
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "fail" "No hosts: line found"
|
||||
else
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "fail" "hosts line: $hosts_line"
|
||||
fi
|
||||
else
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "skip" "File not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
}
|
||||
|
||||
sec_tests_shutdown_schedule() {
|
||||
# ==================================================================
|
||||
# SHUTDOWN SCHEDULE TESTS
|
||||
# ==================================================================
|
||||
echo "--- SHUTDOWN SCHEDULE ---"
|
||||
|
||||
# Test 8: shutdown-schedule.conf is immutable
|
||||
if [[ -f /etc/shutdown-schedule.conf ]]; then
|
||||
if lsattr /etc/shutdown-schedule.conf 2>/dev/null | grep -q '^....i'; then
|
||||
test_result "/etc/shutdown-schedule.conf is immutable" "pass"
|
||||
else
|
||||
test_result "/etc/shutdown-schedule.conf is immutable" "fail" "chattr +i not set"
|
||||
fi
|
||||
else
|
||||
test_result "/etc/shutdown-schedule.conf is immutable" "skip" "Not installed"
|
||||
fi
|
||||
|
||||
# Test 9: shutdown timer is active
|
||||
if systemctl is-active --quiet day-specific-shutdown.timer 2>/dev/null; then
|
||||
test_result "day-specific-shutdown.timer is active" "pass"
|
||||
else
|
||||
test_result "day-specific-shutdown.timer is active" "fail" "Timer not running"
|
||||
fi
|
||||
|
||||
# Test 10: shutdown schedule guard
|
||||
# The shutdown flow no longer powers the machine off — day-specific-shutdown
|
||||
# now hands over to night-lockdown-enter.sh, which tears down the GUI and masks
|
||||
# the TTY login while leaving the servers up. This guard was retired with the
|
||||
# old poweroff path, so a *disabled* unit is the intended state, not a fault.
|
||||
# An enabled-but-inactive unit would still mean something is wrong, so those
|
||||
# two cases are distinguished rather than both being tolerated.
|
||||
# `systemctl is-enabled` prints the state on stdout *and* exits non-zero when
|
||||
# that state is "disabled", so `|| echo disabled` would append a second line
|
||||
# and never compare equal. Capture stdout and discard the exit status instead.
|
||||
shutdown_guard_enabled=$(systemctl is-enabled shutdown-schedule-guard.path 2>/dev/null || true)
|
||||
[[ -n "$shutdown_guard_enabled" ]] || shutdown_guard_enabled=unknown
|
||||
if systemctl is-active --quiet guard-file@shutdown-schedule.path 2>/dev/null ||
|
||||
systemctl is-active --quiet shutdown-schedule-guard.path 2>/dev/null; then
|
||||
# guard-file@shutdown-schedule.path is the guard-lib replacement and is the
|
||||
# unit actually protecting the schedule today, so this is a pass, not a
|
||||
# concession that the schedule went unguarded.
|
||||
test_result "shutdown schedule is guarded" "pass"
|
||||
elif [[ "$shutdown_guard_enabled" == "disabled" ]]; then
|
||||
test_result "shutdown-schedule-guard.path is active" "skip" \
|
||||
"intentionally retired with the old poweroff flow (night lockdown replaced it)"
|
||||
else
|
||||
test_result "shutdown-schedule-guard.path is active" "fail" \
|
||||
"unit is $shutdown_guard_enabled but not running"
|
||||
fi
|
||||
|
||||
# Test 11: Unlock script has obscure name (no helpful path)
|
||||
if [[ -f /usr/local/sbin/.sd-sched-mgmt ]]; then
|
||||
test_result "Unlock script uses obscure name" "pass"
|
||||
elif [[ -f /usr/local/sbin/unlock-shutdown-schedule ]]; then
|
||||
test_result "Unlock script uses obscure name" "fail" "Still using obvious name"
|
||||
else
|
||||
test_result "Unlock script uses obscure name" "skip" "Not installed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
}
|
||||
73
linux_configuration/tests/lib/security_pacman.sh
Executable file
73
linux_configuration/tests/lib/security_pacman.sh
Executable file
@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# Security hardening tests: pacman wrapper and its policy files.
|
||||
# Sourced by test_security_hardening.sh; inherits its strict mode,
|
||||
# colors, counters (PASS/FAIL/SKIP), LIVE_SECTION and test_result().
|
||||
|
||||
sec_tests_pacman_wrapper() {
|
||||
# ==================================================================
|
||||
# PACMAN WRAPPER TESTS
|
||||
# ==================================================================
|
||||
echo "--- PACMAN WRAPPER ---"
|
||||
|
||||
# Test 12: pacman wrapper is installed
|
||||
if [[ -L /usr/bin/pacman ]] && [[ -f /usr/bin/pacman.orig ]]; then
|
||||
test_result "pacman wrapper installed" "pass"
|
||||
else
|
||||
if [[ ! -L /usr/bin/pacman ]]; then
|
||||
test_result "pacman wrapper installed" "fail" "/usr/bin/pacman is not a symlink"
|
||||
else
|
||||
test_result "pacman wrapper installed" "fail" "/usr/bin/pacman.orig not found"
|
||||
fi
|
||||
fi
|
||||
|
||||
live_section 0 # repository checks from here on
|
||||
|
||||
# Test 13: google-chrome is blocked
|
||||
blocked_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_blocked_keywords.txt"
|
||||
if [[ -f "$blocked_file" ]]; then
|
||||
if grep -qi "google-chrome" "$blocked_file"; then
|
||||
test_result "google-chrome in blocked list" "pass"
|
||||
else
|
||||
test_result "google-chrome in blocked list" "fail" "Not found in $blocked_file"
|
||||
fi
|
||||
else
|
||||
test_result "google-chrome in blocked list" "skip" "Blocked keywords file not found"
|
||||
fi
|
||||
|
||||
# Test 14: chromium is blocked
|
||||
if [[ -f "$blocked_file" ]]; then
|
||||
if grep -qi "^chromium$" "$blocked_file"; then
|
||||
test_result "chromium in blocked list" "pass"
|
||||
else
|
||||
test_result "chromium in blocked list" "fail" "Not found in $blocked_file"
|
||||
fi
|
||||
else
|
||||
test_result "chromium in blocked list" "skip" "Blocked keywords file not found"
|
||||
fi
|
||||
|
||||
# Test 15: Policy integrity file exists
|
||||
# Lives under /var on the configured host, so it is a live check despite
|
||||
# sitting among the repository ones.
|
||||
live_section 1
|
||||
if [[ -f /var/lib/pacman-wrapper/policy.sha256 ]]; then
|
||||
test_result "Pacman policy integrity file exists" "pass"
|
||||
else
|
||||
test_result "Pacman policy integrity file exists" "fail" "Not found"
|
||||
fi
|
||||
live_section 0
|
||||
|
||||
# Test 16: LeechBlock auto-install function exists in wrapper
|
||||
wrapper_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh"
|
||||
if [[ -f "$wrapper_file" ]]; then
|
||||
if grep -q "auto_install_leechblock" "$wrapper_file"; then
|
||||
test_result "LeechBlock auto-install function exists" "pass"
|
||||
else
|
||||
test_result "LeechBlock auto-install function exists" "fail" "Function not found"
|
||||
fi
|
||||
else
|
||||
test_result "LeechBlock auto-install function exists" "skip" "Wrapper file not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
}
|
||||
@ -66,6 +66,14 @@ test_result() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Setter for LIVE_SECTION. The section libs are linted in isolation, where a
|
||||
# bare `LIVE_SECTION=1` looks like an unused variable (SC2034) because its only
|
||||
# reader — test_result, above — lives in this file. Going through a function
|
||||
# makes the write a real reference instead of needing a suppression.
|
||||
live_section() {
|
||||
LIVE_SECTION="$1"
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
@ -76,330 +84,23 @@ echo ""
|
||||
echo "Testing components in: $REPO_DIR"
|
||||
echo ""
|
||||
|
||||
# ==================================================================
|
||||
# HOSTS GUARD TESTS
|
||||
# ==================================================================
|
||||
LIVE_SECTION=1 # live-host checks begin
|
||||
echo "--- HOSTS GUARD ---"
|
||||
# The test sections live in libs to keep this file under the 250-line cap.
|
||||
# They are functions, but they mutate this script's counters and LIVE_SECTION
|
||||
# exactly as the former top-level blocks did — sourcing shares the shell.
|
||||
LIB_DIR="$SCRIPT_DIR/lib"
|
||||
# shellcheck source=lib/security_hosts_shutdown.sh
|
||||
source "$LIB_DIR/security_hosts_shutdown.sh"
|
||||
# shellcheck source=lib/security_pacman.sh
|
||||
source "$LIB_DIR/security_pacman.sh"
|
||||
# shellcheck source=lib/security_apps.sh
|
||||
source "$LIB_DIR/security_apps.sh"
|
||||
|
||||
# Test 1: /etc/hosts is immutable
|
||||
if [[ -f /etc/hosts ]]; then
|
||||
if lsattr /etc/hosts 2>/dev/null | grep -q '^....i'; then
|
||||
test_result "/etc/hosts is immutable" "pass"
|
||||
else
|
||||
test_result "/etc/hosts is immutable" "fail" "chattr +i not set"
|
||||
fi
|
||||
else
|
||||
test_result "/etc/hosts is immutable" "skip" "File not found"
|
||||
fi
|
||||
|
||||
# Test 2: the hosts file watcher is active
|
||||
# The 2026-07-17 guard-lib refactor replaced the standalone hosts-guard.path
|
||||
# with the templated guard-file@hosts.path. Accept either, so this passes on a
|
||||
# migrated host and on one still running the legacy unit. Checking only the old
|
||||
# name reported the protection as down when it was simply renamed.
|
||||
if systemctl is-active --quiet guard-file@hosts.path 2>/dev/null ||
|
||||
systemctl is-active --quiet hosts-guard.path 2>/dev/null; then
|
||||
test_result "hosts file watcher is active" "pass"
|
||||
else
|
||||
test_result "hosts file watcher is active" "fail" \
|
||||
"neither guard-file@hosts.path nor hosts-guard.path is running"
|
||||
fi
|
||||
|
||||
# Test 3: /etc/hosts is bind-mounted read-only
|
||||
# guard-bind-mount@hosts.service superseded hosts-bind-mount.service. The mount
|
||||
# itself is the thing that matters, so check that too — it is the real
|
||||
# invariant, independent of which unit established it.
|
||||
if systemctl is-active --quiet guard-bind-mount@hosts.service 2>/dev/null ||
|
||||
systemctl is-active --quiet hosts-bind-mount.service 2>/dev/null ||
|
||||
findmnt -no OPTIONS /etc/hosts 2>/dev/null | grep -q '\bro\b'; then
|
||||
test_result "/etc/hosts bind-mounted read-only" "pass"
|
||||
else
|
||||
test_result "/etc/hosts bind-mounted read-only" "fail" \
|
||||
"no bind-mount unit active and /etc/hosts is not mounted read-only"
|
||||
fi
|
||||
|
||||
# Test 4: Canonical hosts copy exists
|
||||
if [[ -f /usr/local/share/locked-hosts ]]; then
|
||||
test_result "Canonical hosts copy exists" "pass"
|
||||
else
|
||||
test_result "Canonical hosts copy exists" "fail" "Not found at /usr/local/share/locked-hosts"
|
||||
fi
|
||||
|
||||
# Test 5: the nsswitch watcher is active
|
||||
# guard-file@nsswitch.path superseded nsswitch-guard.path. The legacy unit is
|
||||
# left in a failed state on migrated hosts (it and the new watcher both
|
||||
# enforced the same file, so it retriggered itself into systemd's start
|
||||
# limit) — that is obsolete-unit noise, not a lapse in protection.
|
||||
if systemctl is-active --quiet guard-file@nsswitch.path 2>/dev/null ||
|
||||
systemctl is-active --quiet nsswitch-guard.path 2>/dev/null; then
|
||||
test_result "nsswitch watcher is active" "pass"
|
||||
else
|
||||
test_result "nsswitch watcher is active" "fail" \
|
||||
"neither guard-file@nsswitch.path nor nsswitch-guard.path is running"
|
||||
fi
|
||||
|
||||
# Test 6: /etc/nsswitch.conf is immutable (NEW)
|
||||
if [[ -f /etc/nsswitch.conf ]]; then
|
||||
if lsattr /etc/nsswitch.conf 2>/dev/null | grep -q '^....i'; then
|
||||
test_result "/etc/nsswitch.conf is immutable" "pass"
|
||||
else
|
||||
test_result "/etc/nsswitch.conf is immutable" "fail" "chattr +i not set"
|
||||
fi
|
||||
else
|
||||
test_result "/etc/nsswitch.conf is immutable" "skip" "File not found"
|
||||
fi
|
||||
|
||||
# Test 7: nsswitch.conf has correct hosts line
|
||||
if [[ -f /etc/nsswitch.conf ]]; then
|
||||
hosts_line=$(grep "^hosts:" /etc/nsswitch.conf 2>/dev/null || true)
|
||||
if echo "$hosts_line" | grep -q 'files.*dns\|files.*myhostname'; then
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "pass"
|
||||
elif [[ -z "$hosts_line" ]]; then
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "fail" "No hosts: line found"
|
||||
else
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "fail" "hosts line: $hosts_line"
|
||||
fi
|
||||
else
|
||||
test_result "nsswitch.conf has 'files' before 'dns'" "skip" "File not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ==================================================================
|
||||
# SHUTDOWN SCHEDULE TESTS
|
||||
# ==================================================================
|
||||
echo "--- SHUTDOWN SCHEDULE ---"
|
||||
|
||||
# Test 8: shutdown-schedule.conf is immutable
|
||||
if [[ -f /etc/shutdown-schedule.conf ]]; then
|
||||
if lsattr /etc/shutdown-schedule.conf 2>/dev/null | grep -q '^....i'; then
|
||||
test_result "/etc/shutdown-schedule.conf is immutable" "pass"
|
||||
else
|
||||
test_result "/etc/shutdown-schedule.conf is immutable" "fail" "chattr +i not set"
|
||||
fi
|
||||
else
|
||||
test_result "/etc/shutdown-schedule.conf is immutable" "skip" "Not installed"
|
||||
fi
|
||||
|
||||
# Test 9: shutdown timer is active
|
||||
if systemctl is-active --quiet day-specific-shutdown.timer 2>/dev/null; then
|
||||
test_result "day-specific-shutdown.timer is active" "pass"
|
||||
else
|
||||
test_result "day-specific-shutdown.timer is active" "fail" "Timer not running"
|
||||
fi
|
||||
|
||||
# Test 10: shutdown schedule guard
|
||||
# The shutdown flow no longer powers the machine off — day-specific-shutdown
|
||||
# now hands over to night-lockdown-enter.sh, which tears down the GUI and masks
|
||||
# the TTY login while leaving the servers up. This guard was retired with the
|
||||
# old poweroff path, so a *disabled* unit is the intended state, not a fault.
|
||||
# An enabled-but-inactive unit would still mean something is wrong, so those
|
||||
# two cases are distinguished rather than both being tolerated.
|
||||
# `systemctl is-enabled` prints the state on stdout *and* exits non-zero when
|
||||
# that state is "disabled", so `|| echo disabled` would append a second line
|
||||
# and never compare equal. Capture stdout and discard the exit status instead.
|
||||
shutdown_guard_enabled=$(systemctl is-enabled shutdown-schedule-guard.path 2>/dev/null || true)
|
||||
[[ -n "$shutdown_guard_enabled" ]] || shutdown_guard_enabled=unknown
|
||||
if systemctl is-active --quiet guard-file@shutdown-schedule.path 2>/dev/null ||
|
||||
systemctl is-active --quiet shutdown-schedule-guard.path 2>/dev/null; then
|
||||
# guard-file@shutdown-schedule.path is the guard-lib replacement and is the
|
||||
# unit actually protecting the schedule today, so this is a pass, not a
|
||||
# concession that the schedule went unguarded.
|
||||
test_result "shutdown schedule is guarded" "pass"
|
||||
elif [[ "$shutdown_guard_enabled" == "disabled" ]]; then
|
||||
test_result "shutdown-schedule-guard.path is active" "skip" \
|
||||
"intentionally retired with the old poweroff flow (night lockdown replaced it)"
|
||||
else
|
||||
test_result "shutdown-schedule-guard.path is active" "fail" \
|
||||
"unit is $shutdown_guard_enabled but not running"
|
||||
fi
|
||||
|
||||
# Test 11: Unlock script has obscure name (no helpful path)
|
||||
if [[ -f /usr/local/sbin/.sd-sched-mgmt ]]; then
|
||||
test_result "Unlock script uses obscure name" "pass"
|
||||
elif [[ -f /usr/local/sbin/unlock-shutdown-schedule ]]; then
|
||||
test_result "Unlock script uses obscure name" "fail" "Still using obvious name"
|
||||
else
|
||||
test_result "Unlock script uses obscure name" "skip" "Not installed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ==================================================================
|
||||
# PACMAN WRAPPER TESTS
|
||||
# ==================================================================
|
||||
echo "--- PACMAN WRAPPER ---"
|
||||
|
||||
# Test 12: pacman wrapper is installed
|
||||
if [[ -L /usr/bin/pacman ]] && [[ -f /usr/bin/pacman.orig ]]; then
|
||||
test_result "pacman wrapper installed" "pass"
|
||||
else
|
||||
if [[ ! -L /usr/bin/pacman ]]; then
|
||||
test_result "pacman wrapper installed" "fail" "/usr/bin/pacman is not a symlink"
|
||||
else
|
||||
test_result "pacman wrapper installed" "fail" "/usr/bin/pacman.orig not found"
|
||||
fi
|
||||
fi
|
||||
|
||||
LIVE_SECTION=0 # repository checks from here on
|
||||
|
||||
# Test 13: google-chrome is blocked
|
||||
blocked_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_blocked_keywords.txt"
|
||||
if [[ -f "$blocked_file" ]]; then
|
||||
if grep -qi "google-chrome" "$blocked_file"; then
|
||||
test_result "google-chrome in blocked list" "pass"
|
||||
else
|
||||
test_result "google-chrome in blocked list" "fail" "Not found in $blocked_file"
|
||||
fi
|
||||
else
|
||||
test_result "google-chrome in blocked list" "skip" "Blocked keywords file not found"
|
||||
fi
|
||||
|
||||
# Test 14: chromium is blocked
|
||||
if [[ -f "$blocked_file" ]]; then
|
||||
if grep -qi "^chromium$" "$blocked_file"; then
|
||||
test_result "chromium in blocked list" "pass"
|
||||
else
|
||||
test_result "chromium in blocked list" "fail" "Not found in $blocked_file"
|
||||
fi
|
||||
else
|
||||
test_result "chromium in blocked list" "skip" "Blocked keywords file not found"
|
||||
fi
|
||||
|
||||
# Test 15: Policy integrity file exists
|
||||
# Lives under /var on the configured host, so it is a live check despite
|
||||
# sitting among the repository ones.
|
||||
LIVE_SECTION=1
|
||||
if [[ -f /var/lib/pacman-wrapper/policy.sha256 ]]; then
|
||||
test_result "Pacman policy integrity file exists" "pass"
|
||||
else
|
||||
test_result "Pacman policy integrity file exists" "fail" "Not found"
|
||||
fi
|
||||
LIVE_SECTION=0
|
||||
|
||||
# Test 16: LeechBlock auto-install function exists in wrapper
|
||||
wrapper_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh"
|
||||
if [[ -f "$wrapper_file" ]]; then
|
||||
if grep -q "auto_install_leechblock" "$wrapper_file"; then
|
||||
test_result "LeechBlock auto-install function exists" "pass"
|
||||
else
|
||||
test_result "LeechBlock auto-install function exists" "fail" "Function not found"
|
||||
fi
|
||||
else
|
||||
test_result "LeechBlock auto-install function exists" "skip" "Wrapper file not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ==================================================================
|
||||
# COMPULSIVE BLOCK TESTS
|
||||
# ==================================================================
|
||||
echo "--- COMPULSIVE OPENING BLOCK ---"
|
||||
|
||||
compulsive_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh"
|
||||
|
||||
# Test 17: Auto-close timer configuration exists
|
||||
if [[ -f "$compulsive_file" ]]; then
|
||||
if grep -q "AUTO_CLOSE_TIMEOUT_MINUTES" "$compulsive_file"; then
|
||||
test_result "Auto-close timer configuration exists" "pass"
|
||||
else
|
||||
test_result "Auto-close timer configuration exists" "fail" "Variable not found"
|
||||
fi
|
||||
else
|
||||
test_result "Auto-close timer configuration exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 18: launch_with_timer function exists
|
||||
if [[ -f "$compulsive_file" ]]; then
|
||||
if grep -q "launch_with_timer" "$compulsive_file"; then
|
||||
test_result "launch_with_timer function exists" "pass"
|
||||
else
|
||||
test_result "launch_with_timer function exists" "fail" "Function not found"
|
||||
fi
|
||||
else
|
||||
test_result "launch_with_timer function exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 19: Compulsive block wrappers installed
|
||||
wrappers_ok=true
|
||||
for app in beeper signal-desktop discord; do
|
||||
if [[ -f "/usr/bin/$app" ]]; then
|
||||
if grep -q "block-compulsive-opening" "/usr/bin/$app" 2>/dev/null; then
|
||||
: # OK
|
||||
else
|
||||
wrappers_ok=false
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [[ "$wrappers_ok" == true ]]; then
|
||||
test_result "Compulsive block wrappers installed" "pass"
|
||||
else
|
||||
test_result "Compulsive block wrappers installed" "fail" "Some wrappers missing or incorrect"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ==================================================================
|
||||
# SCREEN LOCKER TESTS
|
||||
# ==================================================================
|
||||
echo "--- SCREEN LOCKER ---"
|
||||
|
||||
screen_locker="$HOME/testsAndMisc/python_pkg/screen_locker/screen_lock.py"
|
||||
|
||||
# Test 20: Screen locker exists
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
test_result "Screen locker script exists" "pass"
|
||||
else
|
||||
test_result "Screen locker script exists" "skip" "Not found at expected location"
|
||||
fi
|
||||
|
||||
# Test 21: Running option removed
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
# Check that there's no "Running" button in ask_workout_type
|
||||
if grep -A 20 "def ask_workout_type" "$screen_locker" | grep -q '"Running"'; then
|
||||
test_result "Running workout option removed" "fail" "Still present in ask_workout_type"
|
||||
else
|
||||
test_result "Running workout option removed" "pass"
|
||||
fi
|
||||
else
|
||||
test_result "Running workout option removed" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 22: Table tennis minimum sets validation
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
if grep -q "MIN_TABLE_TENNIS_SETS" "$screen_locker"; then
|
||||
test_result "Table tennis minimum sets validation exists" "pass"
|
||||
else
|
||||
test_result "Table tennis minimum sets validation exists" "fail" "Constant not found"
|
||||
fi
|
||||
else
|
||||
test_result "Table tennis minimum sets validation exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 23: Table tennis verification question
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
if grep -q "ask_table_tennis_verification" "$screen_locker"; then
|
||||
test_result "Table tennis verification question exists" "pass"
|
||||
else
|
||||
test_result "Table tennis verification question exists" "fail" "Function not found"
|
||||
fi
|
||||
else
|
||||
test_result "Table tennis verification question exists" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
# Test 24: 60 second submit delay for table tennis
|
||||
if [[ -f "$screen_locker" ]]; then
|
||||
if grep -q "TABLE_TENNIS_SUBMIT_DELAY = 60" "$screen_locker"; then
|
||||
test_result "Table tennis 60-second submit delay" "pass"
|
||||
else
|
||||
test_result "Table tennis 60-second submit delay" "fail" "Constant not set to 60"
|
||||
fi
|
||||
else
|
||||
test_result "Table tennis 60-second submit delay" "skip" "Script not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
# Order matters: it is the order the sections ran in before the split.
|
||||
sec_tests_hosts_guard
|
||||
sec_tests_shutdown_schedule
|
||||
sec_tests_pacman_wrapper
|
||||
sec_tests_compulsive_block
|
||||
sec_tests_screen_locker
|
||||
|
||||
# ==================================================================
|
||||
# SUMMARY
|
||||
|
||||
100
meta/scripts/_session_autopsy_mutations.py
Executable file
100
meta/scripts/_session_autopsy_mutations.py
Executable file
@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The mutation operators: one named defect each, injected into a hook copy.
|
||||
|
||||
Split out of ``mutate_session_autopsy_hooks.py`` to keep both files under the
|
||||
repository's 250-line cap. Every function here takes the text of one hook and
|
||||
returns it with exactly one guard broken; the driver applies them one at a time
|
||||
and records which tests notice.
|
||||
|
||||
Nothing here touches the filesystem -- these are pure text transforms, which is
|
||||
what makes the driver's "the deployed hooks are never modified" guarantee easy
|
||||
to see.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
END_HOOK = "session_autopsy_end.sh"
|
||||
START_HOOK = "session_autopsy_start.sh"
|
||||
_GUTTED = "#!/bin/bash\nexit 0\n"
|
||||
|
||||
|
||||
def _gut(_text: str) -> str:
|
||||
"""Replace an entire hook body with a bare successful exit."""
|
||||
return _GUTTED
|
||||
|
||||
|
||||
def _drop_transcript_guard(text: str) -> str:
|
||||
"""Remove the SessionEnd guard that requires an existing transcript."""
|
||||
return re.sub(
|
||||
r'if \[\[ -z "\$transcript".*?\nfi\n',
|
||||
"",
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _widen_count(text: str) -> str:
|
||||
"""Make a zero candidate count announce itself."""
|
||||
return text.replace("(( count > 0 ))", "(( count >= 0 ))")
|
||||
|
||||
|
||||
def _drop_numeric_guard(text: str) -> str:
|
||||
"""Drop the count regex guard, leaving the arithmetic syntactically valid."""
|
||||
return text.replace(
|
||||
'[[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 ))',
|
||||
"(( count > 0 ))",
|
||||
)
|
||||
|
||||
|
||||
def _drop_readable_guard(text: str) -> str:
|
||||
"""Remove the early exit taken when the state file cannot be read."""
|
||||
return re.sub(
|
||||
r'if \[\[ ! -r "\$STATE" \]\]; then\n.*?\nfi\n',
|
||||
"",
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _always_announce(text: str) -> str:
|
||||
"""Announce unconditionally -- the defect every silence test exists to catch."""
|
||||
return re.sub(r'if \[\[ "\$count".*?; then', "if true; then", text)
|
||||
|
||||
|
||||
def _unguarded_and_always_announce(text: str) -> str:
|
||||
"""Drop the readability guard AND announce unconditionally.
|
||||
|
||||
Needed because the readability guard is redundant on its own: jq's stderr is
|
||||
suppressed and an empty count already fails the arithmetic, so removing it
|
||||
changes nothing observable. Only removing it together with the count
|
||||
condition can reach the no-state and unreadable-state cases.
|
||||
"""
|
||||
return _always_announce(_drop_readable_guard(text))
|
||||
|
||||
|
||||
class Mutation(NamedTuple):
|
||||
"""One named defect injected into one hook."""
|
||||
|
||||
name: str
|
||||
hook: str
|
||||
apply: Callable[[str], str]
|
||||
|
||||
|
||||
MUTATIONS = (
|
||||
Mutation("gut-end", END_HOOK, _gut),
|
||||
Mutation("gut-start", START_HOOK, _gut),
|
||||
Mutation("end-no-transcript-guard", END_HOOK, _drop_transcript_guard),
|
||||
Mutation("start-count-ge-zero", START_HOOK, _widen_count),
|
||||
Mutation("start-no-numeric-guard", START_HOOK, _drop_numeric_guard),
|
||||
Mutation("start-no-readable-guard", START_HOOK, _drop_readable_guard),
|
||||
Mutation("start-always-announce", START_HOOK, _always_announce),
|
||||
Mutation("start-unguarded-announce", START_HOOK, _unguarded_and_always_announce),
|
||||
)
|
||||
|
||||
__all__ = ["END_HOOK", "MUTATIONS", "START_HOOK", "Mutation"]
|
||||
@ -32,15 +32,10 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from _session_autopsy_mutations import END_HOOK, MUTATIONS, START_HOOK, Mutation
|
||||
|
||||
_TEST_FILE = "linux_configuration/tests/test_session_autopsy_hooks.py"
|
||||
_END_HOOK = "session_autopsy_end.sh"
|
||||
_START_HOOK = "session_autopsy_start.sh"
|
||||
_GUTTED = "#!/bin/bash\nexit 0\n"
|
||||
_OUTCOME = re.compile(r"::(test_\w+)\s+(PASSED|FAILED|ERROR|SKIPPED)")
|
||||
|
||||
|
||||
@ -57,80 +52,6 @@ def _hooks_dir() -> Path:
|
||||
return Path.home() / ".claude" / "hooks"
|
||||
|
||||
|
||||
def _gut(_text: str) -> str:
|
||||
"""Replace an entire hook body with a bare successful exit."""
|
||||
return _GUTTED
|
||||
|
||||
|
||||
def _drop_transcript_guard(text: str) -> str:
|
||||
"""Remove the SessionEnd guard that requires an existing transcript."""
|
||||
return re.sub(
|
||||
r'if \[\[ -z "\$transcript".*?\nfi\n',
|
||||
"",
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _widen_count(text: str) -> str:
|
||||
"""Make a zero candidate count announce itself."""
|
||||
return text.replace("(( count > 0 ))", "(( count >= 0 ))")
|
||||
|
||||
|
||||
def _drop_numeric_guard(text: str) -> str:
|
||||
"""Drop the count regex guard, leaving the arithmetic syntactically valid."""
|
||||
return text.replace(
|
||||
'[[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 ))',
|
||||
"(( count > 0 ))",
|
||||
)
|
||||
|
||||
|
||||
def _drop_readable_guard(text: str) -> str:
|
||||
"""Remove the early exit taken when the state file cannot be read."""
|
||||
return re.sub(
|
||||
r'if \[\[ ! -r "\$STATE" \]\]; then\n.*?\nfi\n',
|
||||
"",
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _always_announce(text: str) -> str:
|
||||
"""Announce unconditionally -- the defect every silence test exists to catch."""
|
||||
return re.sub(r'if \[\[ "\$count".*?; then', "if true; then", text)
|
||||
|
||||
|
||||
def _unguarded_and_always_announce(text: str) -> str:
|
||||
"""Drop the readability guard AND announce unconditionally.
|
||||
|
||||
Needed because the readability guard is redundant on its own: jq's stderr is
|
||||
suppressed and an empty count already fails the arithmetic, so removing it
|
||||
changes nothing observable. Only removing it together with the count
|
||||
condition can reach the no-state and unreadable-state cases.
|
||||
"""
|
||||
return _always_announce(_drop_readable_guard(text))
|
||||
|
||||
|
||||
class Mutation(NamedTuple):
|
||||
"""One named defect injected into one hook."""
|
||||
|
||||
name: str
|
||||
hook: str
|
||||
apply: Callable[[str], str]
|
||||
|
||||
|
||||
_MUTATIONS = (
|
||||
Mutation("gut-end", _END_HOOK, _gut),
|
||||
Mutation("gut-start", _START_HOOK, _gut),
|
||||
Mutation("end-no-transcript-guard", _END_HOOK, _drop_transcript_guard),
|
||||
Mutation("start-count-ge-zero", _START_HOOK, _widen_count),
|
||||
Mutation("start-no-numeric-guard", _START_HOOK, _drop_numeric_guard),
|
||||
Mutation("start-no-readable-guard", _START_HOOK, _drop_readable_guard),
|
||||
Mutation("start-always-announce", _START_HOOK, _always_announce),
|
||||
Mutation("start-unguarded-announce", _START_HOOK, _unguarded_and_always_announce),
|
||||
)
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
"""Return the repository root (this file lives in ``meta/scripts``)."""
|
||||
return Path(__file__).resolve().parents[2]
|
||||
@ -197,7 +118,7 @@ def _apply(mutation: Mutation, hooks_dir: Path, target_dir: Path) -> None:
|
||||
RuntimeError: If the mutation left the hook text unchanged, which means
|
||||
the hook was edited and the mutation no longer describes it.
|
||||
"""
|
||||
for hook in (_END_HOOK, _START_HOOK):
|
||||
for hook in (END_HOOK, START_HOOK):
|
||||
shutil.copy2(hooks_dir / hook, target_dir / hook)
|
||||
target = target_dir / mutation.hook
|
||||
original = target.read_text(encoding="utf-8")
|
||||
@ -215,7 +136,7 @@ def _apply(mutation: Mutation, hooks_dir: Path, target_dir: Path) -> None:
|
||||
def main() -> int:
|
||||
"""Run every mutation and fail if any test survives them all."""
|
||||
hooks_dir = _hooks_dir()
|
||||
if not all((hooks_dir / hook).is_file() for hook in (_END_HOOK, _START_HOOK)):
|
||||
if not all((hooks_dir / hook).is_file() for hook in (END_HOOK, START_HOOK)):
|
||||
_emit(f"session_autopsy hooks are not deployed under {hooks_dir} -- skipping")
|
||||
return 0
|
||||
|
||||
@ -227,7 +148,7 @@ def main() -> int:
|
||||
return 1
|
||||
|
||||
killed_by: dict[str, list[str]] = {}
|
||||
for mutation in _MUTATIONS:
|
||||
for mutation in MUTATIONS:
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as mutant_name:
|
||||
mutant_dir = Path(mutant_name)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user