check_and_enable_services: split into lib/ under the 250-line cap
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

1225 lines down to 202, with the 21 functions moved into eight sourced
libraries grouped by subject. The entry script now holds only the
configuration, the argument parsing and the order the checks run in.

check_hosts was the one function that could not move verbatim: at 260 lines
it would have stayed over the cap on its own, so its repair half is now
hosts_fix_nsswitch / hosts_fix_resolved / hosts_repair_all in
services_hosts_fix.sh. Repair order is preserved (resolver config, then the
hosts file, then the guard-lib migration), and hosts_repair_all returns
non-zero under --dry-run so a dry run can no longer promote a status to "ok"
on the strength of repairs that never happened.

SERVICE_STATUS was a write-only global across five libs, which is a real
SC2034 rather than noise: the pre-commit hook runs shellcheck with no -x and
genuinely cannot see the one reader. It now goes through set_service_status /
get_service_status, which also rejects a duplicate write — each check owns
exactly one summary row, so a second write means two checks are fighting over
it. No suppression was added anywhere.

Proof the behaviour is unchanged: `--status --dry-run` run as root against
this machine is byte-identical to the same command from a worktree at the
pre-split revision, modulo the Date: line. verify_shell_split.sh reports
check_hosts as the single expected difference; the other 20 functions hash
identically. 72 assertions across three test files pass, with 100% line
coverage on services_common.sh (54/54) and services_report.sh (27/27).

services_units.sh measures 84.51% (60/71). The 11 uncovered lines are the
else-branches of [[ -f /etc/systemd/system/*.service ]] and
[[ -f /usr/local/bin/*.sh ]] probes — absolute root-owned paths that exist on
this machine and that no PATH shim can redirect. Reaching them needs a
root-prefix rewrite of live enforcement logic, which is not a line-count fix
and would forfeit the verbatim-move proof for everything else.

Found while testing, deliberately NOT fixed here: in all three unit checks
the is-active branch assigns status="warning" unconditionally, overwriting
the status="error" the is-enabled branch set just above it. A timer that is
both disabled and inactive therefore never gets repaired, because
report_and_fix only repairs on "error". It is identical in the pre-split
file, so this split neither caused nor hides it. The tests pin the current
behaviour and name it; fixing it changes enforcement and earns its own commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3PzGc75gBcdxP3PkHXGcV
This commit is contained in:
Krzysztof Rudnicki 2026-08-21 13:47:05 +02:00
parent 6691cb2644
commit 9085a4e508
15 changed files with 1973 additions and 1062 deletions

View File

@ -0,0 +1,20 @@
{
"title": "Split check_and_enable_services.sh under the 250-line cap with a tested lib/",
"objective": "Bring linux_configuration/scripts/periodic_background/check_and_enable_services.sh from 1225 lines to under the repo-wide 250-line cap by moving its 21 functions into eight sourced libraries beside it, without changing what the tool does on this machine. This is the first of the three safe-tier files in Phase 1 of the 250-cap campaign.",
"acceptance_criteria": [
"check_and_enable_services.sh and every lib/services_*.sh file is at or under 250 lines; bash meta/scripts/check_file_length.sh --all reports no entry under linux_configuration/scripts/periodic_background/.",
"meta/scripts/verify_shell_split.sh against rev 6691cb26 shows every moved function byte-identical after shfmt minification, with check_hosts as the single expected difference because its repair half was deliberately factored into hosts_fix_nsswitch / hosts_fix_resolved / hosts_repair_all.",
"sudo bash check_and_enable_services.sh --status --dry-run produces output byte-identical to the same command run from a worktree at rev 6691cb26, modulo the Date: line.",
"shellcheck reports zero findings on the entry script, all eight libs and all four test files, both standalone (as the pre-commit hook runs it, with no -x) and with -x.",
"shell_coverage.sh reports 100% line coverage for services_common.sh and services_report.sh.",
"No suppression of any kind is added: no shellcheck disable directive, no noqa, no per-file ignore.",
"New sourced libs carry a shebang and the executable bit, staged with git add --chmod=+x."
],
"out_of_scope": [
"Fixing the latent status-downgrade bug found in services_units.sh, where an unconditional status=\"warning\" on the is-active check overwrites the status=\"error\" from the is-enabled check, so a timer that is both disabled and inactive is never repaired. It is identical in the pre-split file, it changes live enforcement behaviour, and it belongs in its own commit. The tests pin the current behaviour and name it.",
"Threading a root-prefix variable through the 29 absolute root-owned paths the check libs probe (/usr/bin/pacman, /etc/hosts, /etc/systemd/system/*.service, ...). That is a rewrite of live enforcement logic, not a line-count fix, and it would forfeit the verbatim-move proof for the other 20 functions.",
"Reaching 100% coverage on the six libs whose branches are gated by those absolute paths.",
"The other seven files still over the cap."
],
"verifier": "bash meta/scripts/check_file_length.sh --all (no periodic_background entry), bash meta/scripts/verify_shell_split.sh 6691cb26 <old> <old> <new>... (check_hosts the only diff), the three lib/tests/test_services_*.sh scripts (72 assertions, 0 failures), bash meta/scripts/shell_coverage.sh for services_common.sh and services_report.sh (100.00% each), and a diff of sudo bash check_and_enable_services.sh --status --dry-run against the same command at rev 6691cb26."
}

View File

@ -0,0 +1,74 @@
{
"intent": "Bring check_and_enable_services.sh (1225 lines) under the repo-wide 250-line cap by splitting its 21 functions into eight sourced libraries, while leaving what the tool actually does on this machine bit-for-bit unchanged.",
"scope": [
"linux_configuration/scripts/periodic_background/check_and_enable_services.sh — 1225 lines down to 202",
"linux_configuration/scripts/periodic_background/lib/services_{common,wrappers,units,hosts,hosts_fix,apps,browser,report}.sh — new",
"linux_configuration/scripts/periodic_background/lib/tests/{services_harness,test_services_common,test_services_report,test_services_units}.sh — new",
"Non-goal: fixing the latent status-downgrade bug this work uncovered in the three unit checks",
"Non-goal: making the 29 absolute root-owned probe paths redirectable, which the six path-gated libs would need for full coverage"
],
"changes": [
"Moved all 21 top-level functions into eight libs grouped by subject (wrappers, systemd units, hosts stack, per-app blockers, browser/VM/user-service, reporting), leaving the entry script holding only configuration, argument parsing and the order the checks run in.",
"Factored check_hosts' 107-line repair half into hosts_fix_nsswitch, hosts_fix_resolved and hosts_repair_all in services_hosts_fix.sh, because check_hosts alone was 260 lines and would have stayed over the cap. Repair order is preserved: resolver config first, then the hosts file, then the guard-lib migration. hosts_repair_all returns non-zero under --dry-run so a dry run can never promote a status to ok on the strength of repairs that did not happen.",
"Replaced the write-only cross-file SERVICE_STATUS global with set_service_status / get_service_status in services_common.sh. This removes SC2034 in five libs without a suppression, and the setter rejects a duplicate write, since each check owns exactly one summary row and a second write would mean two checks silently fighting over it.",
"Gave check_periodic_systems an early return when its status is already ok: it skips a report_and_fix call that had nothing to report, and is the genuine read of `status` that shellcheck cannot infer through report_and_fix's nameref.",
"Added a shared test harness that shims systemctl, guardctl, lsattr, chattr, logger and sudo onto PATH and answers from a throwaway $DEV directory, so the tests assert what the checks tried to do rather than the state of this machine."
],
"verification": [
{
"command": "bash meta/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "No linux_configuration/scripts/periodic_background entry remains. Entry script 202 lines; libs 74-189; largest new file services_wrappers.sh at 189."
},
{
"command": "bash meta/scripts/verify_shell_split.sh 6691cb26 check_and_enable_services.sh check_and_enable_services.sh lib/services_*.sh",
"result": "pass",
"evidence": "Exactly one difference reported: check_hosts, whose hash changed because its repair half was deliberately extracted, plus the three new hosts_* helpers. The other 20 functions hash identically after shfmt minification."
},
{
"command": "diff <(sudo bash <worktree@6691cb26>/…/check_and_enable_services.sh --status --dry-run) <(sudo bash …/check_and_enable_services.sh --status --dry-run)",
"result": "pass",
"evidence": "Byte-identical modulo the Date: line, run against the live machine. All 11 checks execute; summary reports pacman_wrapper/makepkg_wrapper/midnight_shutdown/startup_monitor/periodic_systems/hosts/guest_mode_removal ok, compulsive_blocker/leechblock/workout_locker warning, vbox_hosts n/a."
},
{
"command": "lib/tests/test_services_common.sh; lib/tests/test_services_report.sh; lib/tests/test_services_units.sh",
"result": "pass",
"evidence": "46 passed / 0 failed, 11 passed / 0 failed, 15 passed / 0 failed — 72 assertions total."
},
{
"command": "bash meta/scripts/shell_coverage.sh lib/tests/test_services_common.sh services_common.sh 100",
"result": "pass",
"evidence": "services_common.sh: 54/54 lines = 100.00%"
},
{
"command": "bash meta/scripts/shell_coverage.sh lib/tests/test_services_report.sh services_report.sh 100",
"result": "pass",
"evidence": "services_report.sh: 27/27 lines = 100.00%"
},
{
"command": "bash meta/scripts/shell_coverage.sh lib/tests/test_services_units.sh services_units.sh 100",
"result": "below target, recorded not waived",
"evidence": "services_units.sh: 60/71 lines = 84.51%. The 11 uncovered lines (34, 35, 42, 43, 68, 78, 79, 86, 87, 144, 145) are the else-branches of [[ -f /etc/systemd/system/*.service ]] and [[ -f /usr/local/bin/*.sh ]] probes: absolute root-owned paths that exist on this machine and that no PATH shim can redirect. Reaching them needs the root-prefix rewrite recorded as out of scope."
},
{
"command": "shellcheck lib/tests/*.sh lib/services_*.sh check_and_enable_services.sh",
"result": "pass",
"evidence": "Exit 0, zero findings, standalone (as the hook runs it, no -x) and again with -x. No suppression added anywhere."
},
{
"command": "shfmt -d on all thirteen touched shell files",
"result": "pass",
"evidence": "Exit 0, no diff."
}
],
"risks": [
"check_and_enable_services.sh runs hourly as root and repairs the hosts blocking stack, so a behavioural regression here would silently disable enforcement. Mitigated by the byte-identical live-output diff against the pre-split revision, which is a stronger check than the test suite alone.",
"check_hosts is the one function not proven verbatim. Its extracted repair half is only reached when a guard is already unhealthy — a state this machine is not currently in, so the live diff exercises the detection path but not the repair path. The repair path's ordering was preserved by inspection, not by execution.",
"set_service_status now fails on a duplicate write. check_vbox_hosts writes vbox_hosts on two different paths; the early return on the not-installed path is what keeps them mutually exclusive. If a later edit removes that return, the second write becomes a visible error rather than a silent overwrite — noisy, but not a regression in enforcement.",
"Six of the eight libs sit below 100% coverage because their branches are gated by absolute paths. The measured figures are recorded above rather than waived."
],
"rollback": [
"git revert the commit: the split is additive (eight libs, four test files) plus one rewritten entry script, with no state or deployed artifact touched.",
"After rollback, re-run sudo bash check_and_enable_services.sh --status --dry-run and confirm the same 11-row summary, since nothing outside the repo was modified in the first place."
]
}

View File

@ -0,0 +1,119 @@
#!/usr/bin/env bash
# lib/services_apps.sh — per-application blocking checks (messaging-app launch
# wrappers, and the LeechBlock browser extension for the invoking user).
#
# Sourced by check_and_enable_services.sh.
check_compulsive_blocker() {
header "Compulsive Opening Blocker"
local status="ok"
local issues=()
# Check if main script is installed
if [[ -f /usr/local/bin/block-compulsive-opening.sh ]]; then
msg "Blocker script installed at /usr/local/bin/block-compulsive-opening.sh"
else
issues+=("block-compulsive-opening.sh not found in /usr/local/bin")
status="error"
fi
# Check if wrappers are installed for known apps
local checked_any=false
for app in beeper signal-desktop discord; do
local wrapper_path="/usr/bin/$app"
if [[ -f "${wrapper_path}.orig" ]] || [[ -L "$wrapper_path" ]]; then
if [[ -f "${wrapper_path}.orig" ]]; then
msg "$app wrapper installed (original backed up)"
checked_any=true
fi
elif command -v "$app" &>/dev/null; then
issues+=("$app is installed but wrapper not applied")
if [[ $status != "error" ]]; then status="warning"; fi
checked_any=true
fi
done
if [[ $checked_any == false && $status == "ok" ]]; then
note "No target apps (beeper, signal-desktop, discord) found on system"
fi
if [[ $status != "ok" ]]; then
for issue in "${issues[@]}"; do
if [[ $status == "error" ]]; then
err "$issue"
else
warn "$issue"
fi
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 && $status == "error" ]]; then
note "Installing compulsive opening blocker..."
if [[ -f $COMPULSIVE_BLOCK_SCRIPT ]]; then
run bash "$COMPULSIVE_BLOCK_SCRIPT" install
((FIXES_APPLIED++)) || true
else
err_missing_script "Install script not found: $COMPULSIVE_BLOCK_SCRIPT"
fi
fi
fi
set_service_status "compulsive_blocker" "$status"
}
check_leechblock() {
header "LeechBlock Browser Extension"
local status="ok"
local issues=()
local user="${SUDO_USER:-$USER}"
local user_home
user_home="/home/$user"
# Check if LeechBlock is installed for any browser
local leechblock_dir="$user_home/.local/share/leechblockng"
if [[ -d $leechblock_dir ]]; then
msg "LeechBlock directory exists at $leechblock_dir"
else
issues+=("LeechBlock not found at $leechblock_dir")
status="error"
fi
# Check for browser wrappers with LeechBlock
local found_wrapper=false
for desktop_file in "$user_home/.local/share/applications/"*leechblock* "$user_home/.local/share/applications/"*LeechBlock*; do
if [[ -f $desktop_file ]]; then
msg "LeechBlock desktop entry found: $(basename "$desktop_file")"
found_wrapper=true
fi
done
if [[ $found_wrapper == false && -d $leechblock_dir ]]; then
issues+=("No LeechBlock desktop entries found")
if [[ $status != "error" ]]; then status="warning"; fi
fi
if [[ $status != "ok" ]]; then
for issue in "${issues[@]}"; do
if [[ $status == "error" ]]; then
err "$issue"
else
warn "$issue"
fi
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 && $status == "error" ]]; then
note "Installing LeechBlock..."
if [[ -f $LEECHBLOCK_SCRIPT ]]; then
run sudo -u "$user" bash "$LEECHBLOCK_SCRIPT"
((FIXES_APPLIED++)) || true
else
err_missing_script "Install script not found: $LEECHBLOCK_SCRIPT"
fi
fi
fi
set_service_status "leechblock" "$status"
}

View File

@ -0,0 +1,165 @@
#!/usr/bin/env bash
# lib/services_browser.sh — checks whose subject is a browser policy, a VM
# hosts-enforcement marker, or the per-user workout locker service.
#
# Sourced by check_and_enable_services.sh. check_workout_locker queries a USER
# unit from root and must go through user_systemctl (see its comment) rather
# than `sudo -u ... systemctl --user`.
check_guest_mode_removal() {
header "Chromium Guest Mode Removal"
local status="ok"
local issues=()
# Check if managed policy files exist for any browser
local policy_found=false
for policy_dir in \
/etc/chromium/policies/managed \
/etc/opt/chrome/policies/managed \
/etc/thorium/policies/managed \
/etc/brave/policies/managed; do
if [[ -d $policy_dir ]] && ls "$policy_dir"/*.json &>/dev/null 2>&1; then
# Check for guest mode policy
if grep -rl 'BrowserGuestModeEnabled' "$policy_dir" &>/dev/null 2>&1; then
msg "Guest mode policy found in $policy_dir"
policy_found=true
fi
fi
done
if [[ $policy_found == false ]]; then
# Only flag as issue if a Chromium browser is actually installed
if command -v thorium-browser &>/dev/null || command -v chromium &>/dev/null || command -v google-chrome &>/dev/null || command -v brave-browser &>/dev/null; then
issues+=("No guest mode removal policies found for installed browsers")
status="error"
else
note "No Chromium-based browsers detected, skipping"
fi
fi
if [[ $status != "ok" ]]; then
for issue in "${issues[@]}"; do
err "$issue"
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 ]]; then
note "Removing guest mode..."
if [[ -f $REMOVE_GUEST_MODE_SCRIPT ]]; then
run bash "$REMOVE_GUEST_MODE_SCRIPT"
((FIXES_APPLIED++)) || true
else
err_missing_script "Script not found: $REMOVE_GUEST_MODE_SCRIPT"
fi
fi
fi
set_service_status "guest_mode_removal" "$status"
}
check_vbox_hosts() {
header "VirtualBox Hosts Enforcement"
local status="ok"
local issues=()
# Only check if VirtualBox is installed.
# This is NOT "skipped" in the sense of ducking a check: VirtualBox is absent
# AND deliberately blocked by pacman policy — pacman_blocked_keywords.txt lists
# virtualbox/vbox with the comment "can bypass /etc/hosts restrictions", which
# is the very thing this check enforces. With no VirtualBox there are no VMs to
# enforce /etc/hosts inside, so the check is genuinely not applicable. Report
# n/a so it reads as deliberate rather than something that got ducked.
if ! command -v VBoxManage &>/dev/null; then
note "VirtualBox not installed (blocked by pacman policy) — not applicable"
set_service_status "vbox_hosts" "n/a"
return
fi
# Check if enforcement marker exists
if [[ -f /var/lib/vbox-hosts-enforced ]]; then
msg "VirtualBox hosts enforcement marker exists"
else
issues+=("VirtualBox hosts enforcement not applied")
status="error"
fi
if [[ $status != "ok" ]]; then
for issue in "${issues[@]}"; do
err "$issue"
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 ]]; then
note "Enforcing hosts in VirtualBox VMs..."
if [[ -f $VBOX_HOSTS_SCRIPT ]]; then
run bash "$VBOX_HOSTS_SCRIPT"
((FIXES_APPLIED++)) || true
else
err_missing_script "Script not found: $VBOX_HOSTS_SCRIPT"
fi
fi
fi
set_service_status "vbox_hosts" "$status"
}
check_workout_locker() {
header "Workout Lock Screen"
local status="ok"
local issues=()
local user="${SUDO_USER:-$USER}"
# Check screen_lock.py script exists
if [[ -f $WORKOUT_LOCKER_SCRIPT ]]; then
msg "screen_lock.py exists at $WORKOUT_LOCKER_SCRIPT"
else
issues+=("screen_lock.py not found at $WORKOUT_LOCKER_SCRIPT")
status="error"
fi
# Check user service is enabled
if user_systemctl "$user" is-enabled workout-locker.service &>/dev/null 2>&1; then
msg "workout-locker.service is enabled (user service)"
else
issues+=("workout-locker.service is not enabled (user service)")
status="error"
fi
# Check user service is active (advisory — it only runs at login)
if user_systemctl "$user" is-active workout-locker.service &>/dev/null 2>&1; then
msg "workout-locker.service is active"
else
issues+=("workout-locker.service is not active (expected at login time)")
if [[ $status != "error" ]]; then status="warning"; fi
fi
if [[ $status != "ok" ]]; then
for issue in "${issues[@]}"; do
if [[ $status == "error" ]]; then
err "$issue"
else
warn "$issue"
fi
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 && $status == "error" ]]; then
note "Installing workout locker service..."
if [[ -f $WORKOUT_LOCKER_INSTALL_SCRIPT ]]; then
run sudo -u "$user" bash "$WORKOUT_LOCKER_INSTALL_SCRIPT"
((FIXES_APPLIED++)) || true
# Re-verify
if [[ $DRY_RUN -eq 0 ]] && user_systemctl "$user" is-enabled workout-locker.service &>/dev/null 2>&1; then
status="ok"
fi
else
err_missing_script "Install script not found: $WORKOUT_LOCKER_INSTALL_SCRIPT"
fi
fi
fi
set_service_status "workout_locker" "$status"
}

View File

@ -0,0 +1,173 @@
#!/usr/bin/env bash
# lib/services_common.sh — output helpers, root re-exec, deployment-drift
# verification and the shared report-then-fix path used by every check.
#
# Sourced by check_and_enable_services.sh before any check lib. Reads the
# colour globals, DRY_RUN, STATUS_ONLY, ISSUES_FOUND, FIXES_APPLIED and
# SERVICE_STATUS from the caller; appends to MISSING_SCRIPTS.
######################################################################
# Helpers
######################################################################
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" "$*"; }
header() { printf "\n${CYAN}=== %s ===${NC}\n" "$*"; }
err_missing_script() {
err "$*"
logger -t check-and-enable-services -p user.err "MISSING REPAIR SCRIPT: $*"
MISSING_SCRIPTS+=("$*")
}
# Query a USER systemd unit from root.
# `sudo -u <user> systemctl --user ...` does NOT work here: sudo gives it no
# XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS, so it cannot reach the user bus and
# fails with "Failed to connect to user scope bus via local transport". Every
# check using it therefore reported EVERY user service as "not enabled" — a false
# error for services that were enabled and running (workout-locker was reported
# broken for months while working fine). systemd's own error suggests the fix:
# --machine=<user>@.host connects to another user's bus correctly.
user_systemctl() { # <user> <systemctl args...>
local u="$1"
shift
systemctl --user --machine="${u}@.host" "$@"
}
# Are the pacman hooks that unlock/relock the guarded hosts files installed?
# The hosts guard migrated to guard-lib's GENERIC unlock-all/relock-all hooks
# (which cover every registered file-guard: hosts, nsswitch, resolved, ...), and
# the old per-file 10-unlock-etc-hosts.hook / 90-relock-etc-hosts.hook names have
# not existed since. This check was never updated, so it reported a phantom
# "Pacman hooks not installed" warning forever. pacman_wrapper.sh treats the
# guard-lib pair as authoritative (see pacman_hooks_manage_guard_lib) — match it,
# while still accepting the legacy pair if an older install is present.
hosts_pacman_hooks_installed() {
[[ -f /etc/pacman.d/hooks/10-guard-lib-unlock-all.hook ]] &&
[[ -f /etc/pacman.d/hooks/90-guard-lib-relock-all.hook ]]
}
# True if guard-lib's file-guard instance <name> is installed AND healthy:
# the path unit is active and the target carries the immutable attribute.
# `guardctl file-guard status` exits 1 for an unregistered instance (missing
# /etc/guard-lib/targets/<name>.conf), so a missing instance and an unhealthy
# one both fail this check without needing separate handling.
guard_lib_instance_healthy() { # <name>
local name="$1" out
out=$(guardctl file-guard status "$name" 2>/dev/null) || return 1
grep -q '^path unit: active$' <<<"$out" || return 1
grep -q '^target attrs: .*i' <<<"$out" || return 1
}
# Replay a drift manifest written at install time. The manifest holds plain
# sha256sum lines covering BOTH the repo sources an installer copied from and
# the installed copies it produced, so one `sha256sum -c` answers both "has the
# repo moved on?" and "did someone edit the deployed file?".
#
# This exists because an existence check is not a deployment check. On
# 2026-08-03 /usr/local/bin/pacman_wrapper was 19916 B against a 27510 B source
# — missing the policy integrity manifest, the pacman_lock_lib stale-lock fix
# and the guard-lib fallbacks — while every check here reported "ok" hourly,
# because the file was present and /usr/bin/pacman was a symlink. Both of those
# facts stayed true the entire time the wrapper was a week out of date.
#
# Return: 0 verified, 1 drift detected, 2 no manifest (install predates this).
# Callers must treat 2 as actionable rather than a pass: "cannot verify" is
# precisely the state that hid the stale wrapper.
deployment_drift() { # <manifest-path>
local manifest="$1"
[[ -f $manifest ]] || return 2
sha256sum -c --status "$manifest" 2>/dev/null || return 1
return 0
}
run() {
if [[ $DRY_RUN -eq 1 ]]; then
echo -e "${YELLOW}DRY-RUN:${NC} $*"
return 0
else
"$@"
fi
}
require_root() {
if [[ $EUID -ne 0 ]]; then
echo "This script requires root privileges."
echo "Re-executing with sudo..."
exec sudo -E bash "$0" "$@"
fi
}
######################################################################
# Report issues and optionally run fix script
# Usage: report_and_fix issues_array status_var status_key fix_note setup_script verify_service [args...]
######################################################################
report_and_fix() {
local -n _issues=$1
local -n _status=$2
local status_key="$3"
local fix_note="$4"
local setup_script="$5"
local verify_service="${6:-}"
shift 6
local script_args=("$@")
if [[ $_status != "ok" ]]; then
for issue in "${_issues[@]}"; do
if [[ $_status == "error" ]]; then
err "$issue"
else
warn "$issue"
fi
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 && $_status == "error" ]]; then
note "$fix_note"
if [[ -f $setup_script ]]; then
run bash "$setup_script" "${script_args[@]}"
((FIXES_APPLIED++)) || true
# Re-verify after fix
if [[ $DRY_RUN -eq 0 && -n $verify_service ]] && systemctl is-enabled "$verify_service" &>/dev/null; then
_status="ok"
fi
else
err_missing_script "Setup script not found: $setup_script"
fi
fi
fi
set_service_status "$status_key" "$_status"
}
# Record one service's verdict.
#
# The check libs write SERVICE_STATUS while only services_report.sh reads it.
# Split across files that is a write-only global, which shellcheck reports as
# SC2034 in every writing lib — correctly, since the hook runs it with no `-x`
# and cannot see the reader. Routing every write through this setter, which
# lives in the same file as a reader, turns the cross-file global into a
# function call and removes the finding without suppressing it.
#
# Writing an already-recorded key is a caller bug: each check owns exactly one
# key and runs once, so a second write means two checks are fighting over one
# summary row. Fail loudly rather than let the later one win silently.
set_service_status() { # <key> <status>
local key="$1"
local value="$2"
if [[ -n ${SERVICE_STATUS[$key]:-} ]]; then
err "internal error: status for '${key}' already recorded as '$(get_service_status "$key")'"
return 1
fi
SERVICE_STATUS["$key"]="$value"
}
# Read one service's recorded verdict, defaulting to "unknown".
get_service_status() { # <key>
printf '%s\n' "${SERVICE_STATUS[$1]:-unknown}"
}

View File

@ -0,0 +1,169 @@
#!/usr/bin/env bash
# lib/services_hosts.sh — the /etc/hosts blocking stack check: the hosts file
# itself, its immutable attribute, the three guard-lib file-guard instances
# (hosts/nsswitch/resolved), the pacman unlock/relock hooks, and the
# systemd-resolved settings that can silently bypass /etc/hosts.
#
# Sourced by check_and_enable_services.sh. The repair half lives in
# services_hosts_fix.sh so both files stay under the 250-line cap.
check_hosts() {
header "Hosts File and Guards"
local status="ok"
local issues=()
# Check /etc/hosts exists and has content
if [[ -f /etc/hosts ]]; then
local line_count
line_count=$(wc -l </etc/hosts)
if [[ $line_count -gt 100 ]]; then
msg "/etc/hosts exists with $line_count lines (StevenBlack list likely installed)"
else
issues+=("/etc/hosts has only $line_count lines (StevenBlack list may not be installed)")
status="warning"
fi
else
issues+=("/etc/hosts does not exist")
status="error"
fi
# Check if hosts file is immutable
local attrs
attrs=$(lsattr /etc/hosts 2>/dev/null | cut -d' ' -f1 || echo "")
if [[ $attrs == *"i"* ]]; then
msg "/etc/hosts has immutable attribute set"
else
issues+=("/etc/hosts is not immutable")
status="warning"
fi
# Check cached hosts file
if [[ -f /etc/hosts.stevenblack ]]; then
msg "StevenBlack cache exists at /etc/hosts.stevenblack"
else
issues+=("StevenBlack cache not found")
status="warning"
fi
# Check the guard-lib "hosts" file-guard instance (path unit active +
# immutable attribute on the target). Replaces the legacy hosts-guard.path
# / hosts-bind-mount.service / enforce-hosts.sh checks now that this
# machine's migration to guard-lib is complete — see
# migrate_hosts_guard_to_guard_lib.sh.
if guard_lib_instance_healthy hosts; then
msg "guard-lib 'hosts' instance is active and enforced"
else
issues+=("guard-lib 'hosts' instance is missing or unhealthy")
status="error"
fi
# Check pacman hooks
if hosts_pacman_hooks_installed; then
msg "Pacman hooks installed"
else
issues+=("Pacman hooks not installed")
status="warning"
fi
# Check nsswitch.conf has 'files' in hosts line
if [[ -f /etc/nsswitch.conf ]]; then
local nsswitch_hosts
nsswitch_hosts=$(grep '^hosts:' /etc/nsswitch.conf 2>/dev/null || echo "")
if echo "$nsswitch_hosts" | grep -qw 'files'; then
msg "nsswitch.conf hosts line includes 'files'"
else
issues+=("nsswitch.conf hosts line missing 'files' — /etc/hosts is bypassed!")
status="error"
fi
else
issues+=("/etc/nsswitch.conf does not exist")
status="error"
fi
# Check the guard-lib "nsswitch" file-guard instance
if guard_lib_instance_healthy nsswitch; then
msg "guard-lib 'nsswitch' instance is active and enforced"
else
issues+=("guard-lib 'nsswitch' instance is missing or unhealthy")
status="error"
fi
# Check resolved.conf has ReadEtcHosts=yes
if [[ -f /etc/systemd/resolved.conf ]]; then
local read_etc_hosts
read_etc_hosts=$(grep -E '^\s*ReadEtcHosts\s*=' /etc/systemd/resolved.conf 2>/dev/null |
tail -1 | sed 's/.*=\s*//' | tr -d '[:space:]')
if [[ "$read_etc_hosts" == "yes" ]]; then
msg "resolved.conf ReadEtcHosts=yes"
else
issues+=("resolved.conf ReadEtcHosts='$read_etc_hosts' — /etc/hosts is bypassed by systemd-resolved!")
status="error"
fi
# Check DNSOverTLS is not enabled
local dns_over_tls
dns_over_tls=$(grep -E '^\s*DNSOverTLS\s*=' /etc/systemd/resolved.conf 2>/dev/null |
tail -1 | sed 's/.*=\s*//' | tr -d '[:space:]') || true
if [[ -z "$dns_over_tls" || "$dns_over_tls" == "no" ]]; then
msg "resolved.conf DNSOverTLS is disabled"
else
issues+=("resolved.conf DNSOverTLS='$dns_over_tls' — can bypass /etc/hosts!")
status="error"
fi
# Check for drop-in overrides
if [[ -d /etc/systemd/resolved.conf.d ]]; then
local dropin_count
dropin_count=$(find /etc/systemd/resolved.conf.d -name '*.conf' -type f 2>/dev/null | wc -l)
if [[ "$dropin_count" -gt 0 ]]; then
issues+=("Found $dropin_count resolved.conf drop-in override(s) — potential bypass!")
status="error"
fi
fi
# Check immutable attribute
if command -v lsattr &>/dev/null; then
if lsattr /etc/systemd/resolved.conf 2>/dev/null | grep -q '.*i.*e.*'; then
msg "resolved.conf has immutable attribute"
else
issues+=("resolved.conf missing immutable attribute")
[[ "$status" == "ok" ]] && status="warning"
fi
fi
else
issues+=("/etc/systemd/resolved.conf does not exist")
[[ "$status" == "ok" ]] && status="warning"
fi
# Check the guard-lib "resolved" file-guard instance
if guard_lib_instance_healthy resolved; then
msg "guard-lib 'resolved' instance is active and enforced"
else
issues+=("guard-lib 'resolved' instance is missing or unhealthy")
status="error"
fi
# Report issues
if [[ $status != "ok" ]]; then
for issue in "${issues[@]}"; do
if [[ $status == "error" ]]; then
err "$issue"
else
warn "$issue"
fi
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 ]]; then
# The repair half lives in services_hosts_fix.sh; it returns 0 only
# when the post-repair re-verify passes, so a promotion to "ok" here
# always reflects a re-checked machine rather than a fix attempt.
if hosts_repair_all; then
status="ok"
fi
fi
fi
set_service_status "hosts" "$status"
}

View File

@ -0,0 +1,133 @@
#!/usr/bin/env bash
# lib/services_hosts_fix.sh — the repair half of check_hosts.
#
# Split out of services_hosts.sh so both stay under the 250-line cap. The
# detection half decides WHETHER to repair; everything here assumes that
# decision is already made and only does the repairing.
#
# All three helpers go through `run`, so --dry-run prints instead of acting.
# They increment FIXES_APPLIED and may call err_missing_script, exactly as they
# did inline. hosts_repair_all returns 0 when the post-fix re-verify passes, so
# the caller can promote its status to "ok" without reading a global.
# Put 'files' back on the nsswitch.conf hosts line. Without it every /etc/hosts
# entry is bypassed outright, which makes this the highest-severity repair here
# — the blocking stack is silently inert until it is fixed.
hosts_fix_nsswitch() {
[[ -f /etc/nsswitch.conf ]] || return 0
local line
line=$(grep '^hosts:' /etc/nsswitch.conf 2>/dev/null || echo "")
[[ -n $line ]] || return 0
if echo "$line" | grep -qw 'files'; then
return 0
fi
note "Fixing nsswitch.conf — adding 'files' to hosts line..."
# 'files' must precede whichever resolver is present, or the resolver
# answers first and /etc/hosts never gets consulted.
if echo "$line" | grep -qw 'resolve'; then
run sed -i 's/^hosts:\(.*\)resolve/hosts: files\1resolve/' /etc/nsswitch.conf
elif echo "$line" | grep -qw 'dns'; then
run sed -i 's/^hosts:\(.*\)dns/hosts:\1files dns/' /etc/nsswitch.conf
else
run sed -i 's/^hosts:/hosts: files/' /etc/nsswitch.conf
fi
((FIXES_APPLIED++)) || true
msg "nsswitch.conf fixed: $(grep '^hosts:' /etc/nsswitch.conf)"
}
# Repair the three systemd-resolved settings that can bypass /etc/hosts:
# ReadEtcHosts=no, DNSOverTLS enabled, and drop-in overrides of either. The
# file carries the immutable attribute, so each edit is bracketed by chattr.
hosts_fix_resolved() {
[[ -f /etc/systemd/resolved.conf ]] || return 0
local read_etc_hosts
read_etc_hosts=$(grep -E '^\s*ReadEtcHosts\s*=' /etc/systemd/resolved.conf 2>/dev/null |
tail -1 | sed 's/.*=\s*//' | tr -d '[:space:]')
if [[ "$read_etc_hosts" != "yes" ]]; then
note "Fixing resolved.conf — setting ReadEtcHosts=yes..."
chattr -i /etc/systemd/resolved.conf 2>/dev/null || true
if grep -qE '^\s*ReadEtcHosts\s*=' /etc/systemd/resolved.conf; then
run sed -i -E 's/^\s*ReadEtcHosts\s*=.*/ReadEtcHosts=yes/' /etc/systemd/resolved.conf
elif grep -q '^\[Resolve\]' /etc/systemd/resolved.conf; then
run sed -i '/^\[Resolve\]/a ReadEtcHosts=yes' /etc/systemd/resolved.conf
else
printf '\n[Resolve]\nReadEtcHosts=yes\n' >>/etc/systemd/resolved.conf
fi
chattr +i /etc/systemd/resolved.conf 2>/dev/null || true
run systemctl restart systemd-resolved
((FIXES_APPLIED++)) || true
msg "resolved.conf ReadEtcHosts fixed"
fi
local dns_over_tls
dns_over_tls=$(grep -E '^\s*DNSOverTLS\s*=' /etc/systemd/resolved.conf 2>/dev/null |
tail -1 | sed 's/.*=\s*//' | tr -d '[:space:]') || true
if [[ -n "$dns_over_tls" && "$dns_over_tls" != "no" ]]; then
note "Fixing resolved.conf — disabling DNSOverTLS..."
chattr -i /etc/systemd/resolved.conf 2>/dev/null || true
run sed -i -E 's/^\s*DNSOverTLS\s*=.*/#DNSOverTLS=no/' /etc/systemd/resolved.conf
chattr +i /etc/systemd/resolved.conf 2>/dev/null || true
run systemctl restart systemd-resolved
((FIXES_APPLIED++)) || true
msg "resolved.conf DNSOverTLS disabled"
fi
# A drop-in can re-enable either setting without touching resolved.conf, so
# the two repairs above are not durable while any drop-in survives.
if [[ -d /etc/systemd/resolved.conf.d ]]; then
local dropin_count
dropin_count=$(find /etc/systemd/resolved.conf.d -name '*.conf' -type f 2>/dev/null | wc -l)
if [[ "$dropin_count" -gt 0 ]]; then
note "Removing $dropin_count resolved.conf drop-in override(s)..."
chattr -i /etc/systemd/resolved.conf.d 2>/dev/null || true
find /etc/systemd/resolved.conf.d -name '*.conf' -type f -delete
chattr +i /etc/systemd/resolved.conf.d 2>/dev/null || true
run systemctl restart systemd-resolved
((FIXES_APPLIED++)) || true
fi
fi
}
# Reinstall the hosts file and re-run the guard-lib migration when needed, then
# re-verify. Returns 0 only if all four guard-lib facts hold afterwards, which
# is what lets the caller promote its status to "ok".
#
# Under --dry-run nothing was actually changed, so re-verifying would report on
# the unrepaired machine; return 1 there instead of claiming a fix landed.
hosts_repair_all() {
# Order matters and matches the pre-split script: resolver config first, so
# that a hosts file installed below is actually consulted once it lands.
hosts_fix_nsswitch
hosts_fix_resolved
if [[ ! -f /etc/hosts ]] || [[ $(wc -l </etc/hosts) -lt 100 ]]; then
note "Installing hosts file..."
if [[ -f $HOSTS_INSTALL_SCRIPT ]]; then
run bash "$HOSTS_INSTALL_SCRIPT"
((FIXES_APPLIED++)) || true
else
err_missing_script "Hosts install script not found: $HOSTS_INSTALL_SCRIPT"
fi
fi
# The migration script documents itself as idempotent, so re-running it
# against an already-migrated healthy machine is a no-op.
if ! guard_lib_instance_healthy hosts ||
! guard_lib_instance_healthy nsswitch ||
! guard_lib_instance_healthy resolved ||
! hosts_pacman_hooks_installed; then
note "Repairing guard-lib hosts/nsswitch/resolved instances..."
if [[ -f $GUARD_LIB_MIGRATE_SCRIPT ]]; then
run bash "$GUARD_LIB_MIGRATE_SCRIPT"
((FIXES_APPLIED++)) || true
else
err_missing_script "Guard-lib migration script not found: $GUARD_LIB_MIGRATE_SCRIPT"
fi
fi
[[ $DRY_RUN -eq 0 ]] || return 1
guard_lib_instance_healthy hosts &&
guard_lib_instance_healthy nsswitch &&
guard_lib_instance_healthy resolved &&
hosts_pacman_hooks_installed
}

View File

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# lib/services_report.sh — usage text and the end-of-run status summary table.
#
# Sourced by check_and_enable_services.sh. Reads SERVICE_STATUS, ISSUES_FOUND,
# FIXES_APPLIED, DRY_RUN, STATUS_ONLY and the colour globals from the caller.
usage() {
cat <<'EOF'
Check and Enable Digital Wellbeing Services
============================================
Usage: sudo ./check_and_enable_services.sh [options]
Options:
--dry-run Show what would be done without making changes
--status Only show status, don't enable anything
-h, --help Show this help message
Services checked:
1. Pacman wrapper - Policy-aware pacman wrapper with friction mechanics
2. Midnight shutdown - Day-specific automatic shutdown timer
3. Startup monitor - PC startup time monitoring service
4. Periodic systems - Hourly maintenance timer and hosts monitor
5. Hosts and guards - /etc/hosts blocking and protection layers
6. Compulsive blocker - Limits messaging apps to one launch per hour
7. Thorium startup - Auto-launch Thorium with Fitatu on boot
8. LeechBlock - Browser extension for site blocking
9. Guest mode removal - Disable Chromium guest mode via policy
10. VirtualBox hosts - Enforce /etc/hosts inside VMs
11. Workout lock screen - Requires workout logging to unlock screen (user service)
EOF
}
print_summary() {
header "Summary"
echo ""
printf "%-25s %s\n" "Service" "Status"
printf "%-25s %s\n" "-------" "------"
for service in pacman_wrapper makepkg_wrapper midnight_shutdown startup_monitor periodic_systems hosts compulsive_blocker leechblock guest_mode_removal vbox_hosts workout_locker; do
local status
status="$(get_service_status "$service")"
local color
case "$status" in
ok) color=$GREEN ;;
warning) color=$YELLOW ;;
error) color=$RED ;;
"n/a" | skipped) color=$BLUE ;;
*) color=$NC ;;
esac
printf "%-25s ${color}%s${NC}\n" "$service" "$status"
done
echo ""
if [[ $DRY_RUN -eq 1 ]]; then
note "DRY RUN - No changes were made"
fi
if [[ $ISSUES_FOUND -eq 0 ]]; then
msg "All services are properly configured!"
else
if [[ $STATUS_ONLY -eq 1 ]]; then
warn "Found $ISSUES_FOUND service(s) with issues"
note "Run without --status to fix issues"
else
if [[ $FIXES_APPLIED -gt 0 ]]; then
msg "Applied $FIXES_APPLIED fix(es)"
else
warn "Found $ISSUES_FOUND issue(s) but no fixes were applied"
fi
fi
fi
}

View File

@ -0,0 +1,162 @@
#!/usr/bin/env bash
# lib/services_units.sh — systemd timer/service checks for the three units that
# are installed by their own setup scripts (midnight shutdown, PC startup
# monitor, periodic maintenance).
#
# Sourced by check_and_enable_services.sh. All three funnel their findings
# through report_and_fix, which owns the reinstall-and-reverify path.
check_midnight_shutdown() {
header "Midnight Shutdown (Day-Specific Auto-Shutdown)"
local status="ok"
local issues=()
# Check timer
if systemctl is-enabled day-specific-shutdown.timer &>/dev/null; then
msg "day-specific-shutdown.timer is enabled"
else
issues+=("day-specific-shutdown.timer is not enabled")
status="error"
fi
if systemctl is-active day-specific-shutdown.timer &>/dev/null; then
msg "day-specific-shutdown.timer is active"
else
issues+=("day-specific-shutdown.timer is not active")
status="warning"
fi
# Check service file exists
if [[ -f /etc/systemd/system/day-specific-shutdown.service ]]; then
msg "day-specific-shutdown.service file exists"
else
issues+=("day-specific-shutdown.service file missing")
status="error"
fi
# Check management script
if [[ -f /usr/local/bin/day-specific-shutdown-manager.sh ]]; then
msg "Shutdown manager script exists"
else
issues+=("day-specific-shutdown-manager.sh not found")
status="error"
fi
report_and_fix issues status "midnight_shutdown" \
"Setting up midnight shutdown..." \
"$MIDNIGHT_SHUTDOWN_SCRIPT" \
"day-specific-shutdown.timer" \
enable
}
check_startup_monitor() {
header "PC Startup Monitor"
local status="ok"
local issues=()
# Check timer (the timer triggers the service, so we check the timer)
if systemctl is-enabled pc-startup-monitor.timer &>/dev/null; then
msg "pc-startup-monitor.timer is enabled"
else
issues+=("pc-startup-monitor.timer is not enabled")
status="error"
fi
if systemctl is-active pc-startup-monitor.timer &>/dev/null; then
msg "pc-startup-monitor.timer is active"
else
issues+=("pc-startup-monitor.timer is not active")
status="warning"
fi
# Check service file exists
if [[ -f /etc/systemd/system/pc-startup-monitor.service ]]; then
msg "pc-startup-monitor.service file exists"
else
issues+=("pc-startup-monitor.service file missing")
status="error"
fi
# Check monitor script
if [[ -f /usr/local/bin/pc-startup-check.sh ]]; then
msg "Startup check script exists"
else
issues+=("pc-startup-check.sh not found")
status="error"
fi
report_and_fix issues status "startup_monitor" \
"Setting up startup monitor..." \
"$STARTUP_MONITOR_SCRIPT" \
"pc-startup-monitor.timer"
}
check_periodic_systems() {
header "Periodic System Maintenance"
local status="ok"
local issues=()
# Check timer
if systemctl is-enabled periodic-system-maintenance.timer &>/dev/null; then
msg "periodic-system-maintenance.timer is enabled"
else
issues+=("periodic-system-maintenance.timer is not enabled")
status="error"
fi
if systemctl is-active periodic-system-maintenance.timer &>/dev/null; then
msg "periodic-system-maintenance.timer is active"
else
issues+=("periodic-system-maintenance.timer is not active")
status="warning"
fi
# Check startup service
if systemctl is-enabled periodic-system-startup.service &>/dev/null; then
msg "periodic-system-startup.service is enabled"
else
issues+=("periodic-system-startup.service is not enabled")
status="error"
fi
# Check hosts file monitor
if systemctl is-enabled hosts-file-monitor.service &>/dev/null; then
msg "hosts-file-monitor.service is enabled"
else
issues+=("hosts-file-monitor.service is not enabled")
status="error"
fi
if systemctl is-active hosts-file-monitor.service &>/dev/null; then
msg "hosts-file-monitor.service is active"
else
issues+=("hosts-file-monitor.service is not active")
status="warning"
fi
# Check maintenance script
if [[ -f /usr/local/bin/periodic-system-maintenance.sh ]]; then
msg "Maintenance script exists"
else
issues+=("periodic-system-maintenance.sh not found")
status="error"
fi
# report_and_fix takes `status` by nameref and both reads and rewrites it,
# so passing the bare name is the whole interface. shellcheck cannot see
# through a nameref, though, and standalone it reports the assignments
# above as write-only (SC2034). This early return is a genuine read: an
# "ok" service has nothing to report and nothing to fix, so skipping the
# call is also marginally cheaper than having it decide the same thing.
if [[ $status == "ok" ]]; then
set_service_status "periodic_systems" "ok"
return 0
fi
report_and_fix issues status "periodic_systems" \
"Setting up periodic systems..." \
"$PERIODIC_SYSTEM_SCRIPT" \
"periodic-system-maintenance.timer"
}

View File

@ -0,0 +1,189 @@
#!/usr/bin/env bash
# lib/services_wrappers.sh — pacman/makepkg wrapper deployment checks.
#
# Sourced by check_and_enable_services.sh. Each check verifies the symlink, the
# .orig backup, the installed files AND the install-time drift manifest, then
# reinstalls via the matching installer when STATUS_ONLY is off.
check_pacman_wrapper() {
header "Pacman Wrapper"
local status="ok"
local issues=()
# Check if wrapper is installed
if [[ -L /usr/bin/pacman ]]; then
local target
target=$(readlink -f /usr/bin/pacman)
if [[ $target == "/usr/local/bin/pacman_wrapper" ]]; then
msg "Pacman symlink points to wrapper"
else
issues+=("Pacman symlink points to: $target (expected /usr/local/bin/pacman_wrapper)")
status="error"
fi
else
issues+=("Pacman is not a symlink (wrapper not installed)")
status="error"
fi
# Check if original pacman is backed up
if [[ -f /usr/bin/pacman.orig ]]; then
msg "Original pacman backed up at /usr/bin/pacman.orig"
else
issues+=("Original pacman backup not found at /usr/bin/pacman.orig")
status="error"
fi
# Check if wrapper script exists
if [[ -f /usr/local/bin/pacman_wrapper ]]; then
msg "Wrapper script exists at /usr/local/bin/pacman_wrapper"
else
issues+=("Wrapper script not found at /usr/local/bin/pacman_wrapper")
status="error"
fi
# Check supporting files
for file in words.txt pacman_blocked_keywords.txt pacman_whitelist.txt; do
if [[ -f "/usr/local/bin/$file" ]]; then
msg "Supporting file exists: /usr/local/bin/$file"
else
warn "Supporting file missing: /usr/local/bin/$file"
fi
done
# Content drift — the check everything above misses. A correct symlink and a
# present file say nothing about WHICH version is deployed.
local drift_rc=0
deployment_drift "$PACMAN_WRAPPER_MANIFEST" || drift_rc=$?
case $drift_rc in
0) msg "Wrapper matches the source it was installed from (no drift)" ;;
1)
issues+=("Deployed pacman wrapper differs from its install manifest (stale or tampered)")
status="error"
;;
2)
issues+=("No drift manifest at $PACMAN_WRAPPER_MANIFEST — deployed version unverifiable")
status="error"
;;
esac
# Report and fix
if [[ $status == "error" ]]; then
for issue in "${issues[@]}"; do
err "$issue"
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 ]]; then
note "Installing pacman wrapper..."
if [[ -f $PACMAN_WRAPPER_INSTALL ]]; then
run bash "$PACMAN_WRAPPER_INSTALL"
((FIXES_APPLIED++)) || true
# Re-verify after fix. Content is part of "fixed": a reinstall
# that leaves the manifest failing has repaired nothing.
if [[ $DRY_RUN -eq 0 ]] && [[ -L /usr/bin/pacman ]] && [[ -f /usr/bin/pacman.orig ]] &&
[[ -f /usr/local/bin/pacman_wrapper ]] && deployment_drift "$PACMAN_WRAPPER_MANIFEST"; then
status="ok"
fi
else
err_missing_script "Installer script not found: $PACMAN_WRAPPER_INSTALL"
fi
fi
fi
set_service_status "pacman_wrapper" "$status"
}
check_makepkg_wrapper() {
header "Makepkg Wrapper"
local status="ok"
local issues=()
# Wrapper symlink
if [[ -L /usr/bin/makepkg ]]; then
local target
target=$(readlink -f /usr/bin/makepkg)
if [[ $target == "/usr/local/bin/makepkg_wrapper" ]]; then
msg "Makepkg symlink points to wrapper"
else
issues+=("Makepkg symlink points to: $target (expected /usr/local/bin/makepkg_wrapper)")
status="error"
fi
else
issues+=("Makepkg is not a symlink (wrapper not installed)")
status="error"
fi
# Original makepkg backup
if [[ -f /usr/bin/makepkg.orig ]]; then
msg "Original makepkg backed up at /usr/bin/makepkg.orig"
else
issues+=("Original makepkg backup not found at /usr/bin/makepkg.orig")
status="error"
fi
# Wrapper, shared lib, rewrap helper, survival hook
if [[ -f /usr/local/bin/makepkg_wrapper ]]; then
msg "Wrapper script exists at /usr/local/bin/makepkg_wrapper"
else
issues+=("Wrapper script not found at /usr/local/bin/makepkg_wrapper")
status="error"
fi
if [[ -f /usr/local/bin/pacman_lock_lib.sh ]]; then
msg "Shared lock library exists at /usr/local/bin/pacman_lock_lib.sh"
else
issues+=("Shared lock library not found at /usr/local/bin/pacman_lock_lib.sh")
status="error"
fi
if [[ -f /usr/local/bin/rewrap_pkg_managers.sh ]]; then
msg "Rewrap helper exists at /usr/local/bin/rewrap_pkg_managers.sh"
else
issues+=("Rewrap helper not found at /usr/local/bin/rewrap_pkg_managers.sh")
status="error"
fi
if [[ -f /etc/pacman.d/hooks/96-restore-pkg-wrappers.hook ]]; then
msg "Upgrade-survival hook installed"
else
issues+=("Upgrade-survival hook not installed at /etc/pacman.d/hooks/96-restore-pkg-wrappers.hook")
status="error"
fi
# Content drift (see check_pacman_wrapper for why existence is not enough)
local drift_rc=0
deployment_drift "$MAKEPKG_WRAPPER_MANIFEST" || drift_rc=$?
case $drift_rc in
0) msg "Makepkg wrapper matches the source it was installed from (no drift)" ;;
1)
issues+=("Deployed makepkg wrapper differs from its install manifest (stale or tampered)")
status="error"
;;
2)
issues+=("No drift manifest at $MAKEPKG_WRAPPER_MANIFEST — deployed version unverifiable")
status="error"
;;
esac
# Report and fix
if [[ $status == "error" ]]; then
for issue in "${issues[@]}"; do
err "$issue"
done
((ISSUES_FOUND++)) || true
if [[ $STATUS_ONLY -eq 0 ]]; then
note "Installing makepkg wrapper..."
if [[ -f $MAKEPKG_WRAPPER_INSTALL ]]; then
run bash "$MAKEPKG_WRAPPER_INSTALL"
((FIXES_APPLIED++)) || true
if [[ $DRY_RUN -eq 0 ]] && [[ -L /usr/bin/makepkg ]] && [[ -f /usr/bin/makepkg.orig ]] && [[ -f /usr/local/bin/makepkg_wrapper ]]; then
status="ok"
fi
else
err_missing_script "Installer script not found: $MAKEPKG_WRAPPER_INSTALL"
fi
fi
fi
set_service_status "makepkg_wrapper" "$status"
}

View File

@ -0,0 +1,238 @@
#!/usr/bin/env bash
# lib/tests/services_harness.sh — shared fixture for the check_and_enable_services
# split.
#
# Sourced, not executed. Every command the checks reach for — systemctl,
# guardctl, lsattr, chattr, logger, sudo, find, wc — is a PATH shim recording
# its invocation into $DEV/calls, so a test asserts on what the code TRIED to
# do rather than on the state of this machine. Nothing here touches a real unit,
# a real /etc file, or a real installer.
#
# The paths the checks probe (/usr/bin/pacman, /etc/hosts, ...) are absolute and
# cannot be redirected, so the shims answer questions about them from files
# under $DEV instead: a test declares "pretend /etc/hosts has 200 lines" by
# writing $DEV/etc_hosts_lines rather than by creating the file.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1"
local got="$2"
local what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
# Assert $DEV/calls contains a line matching a regex.
_t_called() {
local pattern="$1"
local what="$2"
if grep -qE "$pattern" "${DEV}/calls" 2>/dev/null; then
_t_pass "$what"
else
_t_fail "$what (no call matching /${pattern}/)"
fi
}
_t_not_called() {
local pattern="$1"
local what="$2"
if grep -qE "$pattern" "${DEV}/calls" 2>/dev/null; then
_t_fail "$what (unexpected call matching /${pattern}/)"
else
_t_pass "$what"
fi
}
_t_summary() {
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
}
TEST_TMPDIR="$(mktemp -d)"
trap 'rm -rf "${TEST_TMPDIR}"' EXIT
readonly DEV="${TEST_TMPDIR}/device"
readonly FAKE_BIN="${TEST_TMPDIR}/fake_bin"
mkdir -p "${DEV}" "${FAKE_BIN}"
# --- fake external tools ----------------------------------------------------
# systemctl: `is-enabled`/`is-active <unit>` succeed only when $DEV/enabled or
# $DEV/active lists the unit. Every other subcommand (restart, ...) just
# records. `--user --machine=...` is the user_systemctl shape and is answered
# from the same two files, prefixed "user:".
cat >"${FAKE_BIN}/systemctl" <<'SYSTEMCTLSHIM'
#!/usr/bin/env bash
set -euo pipefail
DEV="${SERVICES_TEST_DEV}"
printf '%s\n' "systemctl $*" >>"${DEV}/calls"
prefix=""
args=()
for a in "$@"; do
case "$a" in
--user) prefix="user:" ;;
--machine=*) ;;
*) args+=("$a") ;;
esac
done
verb="${args[0]:-}"
unit="${args[1]:-}"
case "$verb" in
is-enabled) grep -qxF "${prefix}${unit}" "${DEV}/enabled" 2>/dev/null ;;
is-active) grep -qxF "${prefix}${unit}" "${DEV}/active" 2>/dev/null ;;
*) exit 0 ;;
esac
SYSTEMCTLSHIM
chmod +x "${FAKE_BIN}/systemctl"
# guardctl file-guard status <name>: prints the two lines
# guard_lib_instance_healthy greps for, but only for instances listed in
# $DEV/guard_healthy. An instance in $DEV/guard_degraded prints a status whose
# path unit is inactive; anything else exits 1 (unregistered).
cat >"${FAKE_BIN}/guardctl" <<'GUARDCTLSHIM'
#!/usr/bin/env bash
set -euo pipefail
DEV="${SERVICES_TEST_DEV}"
printf '%s\n' "guardctl $*" >>"${DEV}/calls"
name="${3:-}"
if grep -qxF "$name" "${DEV}/guard_healthy" 2>/dev/null; then
printf 'path unit: active\ntarget attrs: ----i---------e---- /etc/%s\n' "$name"
exit 0
fi
if grep -qxF "$name" "${DEV}/guard_degraded" 2>/dev/null; then
printf 'path unit: inactive\ntarget attrs: ------------------ /etc/%s\n' "$name"
exit 0
fi
exit 1
GUARDCTLSHIM
chmod +x "${FAKE_BIN}/guardctl"
# lsattr: prints an attribute string for paths listed in $DEV/immutable,
# otherwise the all-dashes "no attributes" form.
cat >"${FAKE_BIN}/lsattr" <<'LSATTRSHIM'
#!/usr/bin/env bash
set -euo pipefail
DEV="${SERVICES_TEST_DEV}"
target="${1:-}"
if grep -qxF "$target" "${DEV}/immutable" 2>/dev/null; then
printf '----i---------e---- %s\n' "$target"
else
printf -- '------------------e---- %s\n' "$target"
fi
exit 0
LSATTRSHIM
chmod +x "${FAKE_BIN}/lsattr"
for tool in chattr logger sudo systemd-resolved; do
cat >"${FAKE_BIN}/${tool}" <<SIMPLESHIM
#!/usr/bin/env bash
printf '%s\n' "${tool} \$*" >>"\${SERVICES_TEST_DEV}/calls"
exit 0
SIMPLESHIM
chmod +x "${FAKE_BIN}/${tool}"
done
# `command -v <browser>` decides whether the browser/VBox checks apply. bash's
# builtin `command` cannot be shimmed on PATH, so the tests instead create the
# fake executables themselves under $FAKE_BIN via present_command below.
present_command() { # <name>...
local name
for name in "$@"; do
printf '#!/usr/bin/env bash\nexit 0\n' >"${FAKE_BIN}/${name}"
chmod +x "${FAKE_BIN}/${name}"
done
}
absent_command() { # <name>...
local name
for name in "$@"; do
rm -f "${FAKE_BIN}/${name}"
done
}
export SERVICES_TEST_DEV="${DEV}"
export PATH="${FAKE_BIN}:${PATH}"
# --- globals the libs read --------------------------------------------------
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[0;33m'
BLUE=$'\033[0;34m'
CYAN=$'\033[0;36m'
NC=$'\033[0m'
DRY_RUN=0
STATUS_ONLY=0
ISSUES_FOUND=0
FIXES_APPLIED=0
declare -A SERVICE_STATUS=()
declare -a MISSING_SCRIPTS=()
# Installer paths point into the tmpdir so a test controls whether the repair
# path finds its script (present) or reports it missing (absent).
#
# Exported because the check libs that read them are sourced by the individual
# test files rather than by this harness, so standalone shellcheck sees only
# the write here and reports SC2034. They genuinely cross a process boundary
# too (the require_root probe runs as a child), so export is the accurate
# declaration rather than a way to quiet the warning.
export PACMAN_WRAPPER_INSTALL="${TEST_TMPDIR}/install_pacman_wrapper.sh"
export MAKEPKG_WRAPPER_INSTALL="${TEST_TMPDIR}/install_makepkg_wrapper.sh"
export PACMAN_WRAPPER_MANIFEST="${TEST_TMPDIR}/pacman-source.sha256"
export MAKEPKG_WRAPPER_MANIFEST="${TEST_TMPDIR}/makepkg-source.sha256"
export MIDNIGHT_SHUTDOWN_SCRIPT="${TEST_TMPDIR}/setup_midnight_shutdown.sh"
export STARTUP_MONITOR_SCRIPT="${TEST_TMPDIR}/setup_pc_startup_monitor.sh"
export PERIODIC_SYSTEM_SCRIPT="${TEST_TMPDIR}/setup_periodic_system.sh"
export HOSTS_INSTALL_SCRIPT="${TEST_TMPDIR}/hosts_install.sh"
export GUARD_LIB_MIGRATE_SCRIPT="${TEST_TMPDIR}/migrate_hosts_guard.sh"
export COMPULSIVE_BLOCK_SCRIPT="${TEST_TMPDIR}/block_compulsive_opening.sh"
export LEECHBLOCK_SCRIPT="${TEST_TMPDIR}/install_leechblock.sh"
export REMOVE_GUEST_MODE_SCRIPT="${TEST_TMPDIR}/remove_guest_mode.sh"
export VBOX_HOSTS_SCRIPT="${TEST_TMPDIR}/enforce_vbox_hosts.sh"
export WORKOUT_LOCKER_INSTALL_SCRIPT="${TEST_TMPDIR}/install_systemd.sh"
export WORKOUT_LOCKER_SCRIPT="${TEST_TMPDIR}/screen_lock.py"
# Create an installer stub that records being run, so "did the repair path fire"
# is observable without any real installation happening.
make_installer() { # <path>
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "ran %s $*" >>"%s/calls"\nexit 0\n' \
"$(basename "$1")" "${DEV}" >"$1"
chmod +x "$1"
}
# shellcheck source=../services_common.sh
. "${LIB_DIR}/services_common.sh"
# reset_state — return every mutable global and every $DEV fact to "nothing has
# happened yet", so each test group starts from a known machine.
reset_state() {
rm -f "${DEV:?}"/* 2>/dev/null || true
: >"${DEV}/calls"
DRY_RUN=0
STATUS_ONLY=0
ISSUES_FOUND=0
FIXES_APPLIED=0
SERVICE_STATUS=()
MISSING_SCRIPTS=()
}
reset_state

View File

@ -0,0 +1,247 @@
#!/usr/bin/env bash
# Tests for lib/services_common.sh — output helpers, the status store, the
# dry-run `run` gate, deployment_drift, the guard-lib probe and report_and_fix.
#
# report_and_fix is the one with real branching: status ok vs warning vs error,
# STATUS_ONLY on/off, installer present/missing, and the post-fix re-verify that
# can promote a status to "ok". Each combination gets its own case.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=services_harness.sh
. "${SCRIPT_DIR}/services_harness.sh"
echo "== output helpers =="
reset_state
_t_eq "1" "$(msg yes | grep -c 'yes')" "msg prints its argument"
_t_eq "1" "$(note hi | grep -c 'hi')" "note prints its argument"
_t_eq "1" "$(warn hi | grep -c 'hi')" "warn prints its argument"
_t_eq "1" "$(err hi | grep -c 'hi')" "err prints its argument"
_t_eq "1" "$(header hi | grep -c 'hi')" "header prints its argument"
echo "== err_missing_script records and logs =="
reset_state
err_missing_script "missing: /nope/install.sh" >/dev/null
_t_eq "1" "${#MISSING_SCRIPTS[@]}" "the missing script is recorded"
_t_called 'logger .*MISSING REPAIR SCRIPT' "the miss is logged at error priority"
echo "== status store =="
reset_state
_t_eq "unknown" "$(get_service_status "nothing_set")" "unset key reads as unknown"
set_service_status "hosts" "ok"
_t_eq "ok" "$(get_service_status "hosts")" "a recorded status reads back"
# A second write to one key means two checks are fighting over one summary row.
if set_service_status "hosts" "error" >/dev/null 2>&1; then
_t_fail "a duplicate write is rejected"
else
_t_pass "a duplicate write is rejected"
fi
_t_eq "ok" "$(get_service_status "hosts")" "the rejected write did not overwrite"
echo "== run honours --dry-run =="
reset_state
DRY_RUN=1
out="$(run logger would-have-run)"
_t_called_in() { # <haystack> <needle> <what>
if [[ "$1" == *"$2"* ]]; then
_t_pass "$3"
else
_t_fail "$3 (missing '$2')"
fi
}
_t_called_in "$out" "DRY-RUN:" "dry-run announces instead of running"
_t_not_called 'logger would-have-run' "dry-run did not actually run the command"
DRY_RUN=0
run logger really-ran
_t_called 'logger really-ran' "with dry-run off the command really runs"
echo "== user_systemctl targets the user bus =="
reset_state
printf 'user:workout-locker.service\n' >"${DEV}/enabled"
if user_systemctl kuhy is-enabled workout-locker.service; then
_t_pass "user_systemctl reports an enabled user unit"
else
_t_fail "user_systemctl reports an enabled user unit"
fi
_t_called 'systemctl --user --machine=kuhy@.host' "it connects via --machine, not sudo -u"
echo "== guard_lib_instance_healthy =="
reset_state
printf 'hosts\n' >"${DEV}/guard_healthy"
printf 'nsswitch\n' >"${DEV}/guard_degraded"
if guard_lib_instance_healthy hosts; then
_t_pass "a healthy instance passes"
else
_t_fail "a healthy instance passes"
fi
if guard_lib_instance_healthy nsswitch; then
_t_fail "an inactive path unit fails"
else
_t_pass "an inactive path unit fails"
fi
if guard_lib_instance_healthy resolved; then
_t_fail "an unregistered instance fails"
else
_t_pass "an unregistered instance fails"
fi
echo "== deployment_drift =="
reset_state
manifest="${TEST_TMPDIR}/drift.sha256"
subject="${TEST_TMPDIR}/subject.txt"
printf 'original\n' >"$subject"
(cd "${TEST_TMPDIR}" && sha256sum "$(basename "$subject")" >"$manifest")
rc=0
(cd "${TEST_TMPDIR}" && deployment_drift "$manifest") || rc=$?
_t_eq "0" "$rc" "a matching manifest verifies"
printf 'tampered\n' >"$subject"
rc=0
(cd "${TEST_TMPDIR}" && deployment_drift "$manifest") || rc=$?
_t_eq "1" "$rc" "an edited file is reported as drift"
rc=0
deployment_drift "${TEST_TMPDIR}/absent.sha256" || rc=$?
_t_eq "2" "$rc" "a missing manifest is 'unverifiable', not a pass"
echo "== report_and_fix: an ok service is recorded and left alone =="
reset_state
declare -a issues=()
status="ok"
report_and_fix issues status "startup_monitor" "fixing..." "$STARTUP_MONITOR_SCRIPT" "" >/dev/null
_t_eq "ok" "$(get_service_status "startup_monitor")" "an ok status is recorded"
_t_eq "0" "$ISSUES_FOUND" "an ok service counts no issues"
_t_not_called 'ran setup_pc' "an ok service runs no installer"
echo "== report_and_fix: a warning is reported but never repaired =="
reset_state
make_installer "$STARTUP_MONITOR_SCRIPT"
issues=("timer is not active")
status="warning"
# Captured to a file rather than `$(...)`: report_and_fix mutates ISSUES_FOUND
# and SERVICE_STATUS, and a command substitution would strand both in a
# subshell, making the assertions below silently test nothing.
report_and_fix issues status "startup_monitor" "fixing..." \
"$STARTUP_MONITOR_SCRIPT" "" >"${TEST_TMPDIR}/out.txt"
out="$(cat "${TEST_TMPDIR}/out.txt")"
_t_called_in "$out" "timer is not active" "the warning text is printed"
_t_eq "1" "$ISSUES_FOUND" "a warning counts as an issue"
_t_not_called 'ran setup_pc' "a warning does not trigger the installer"
_t_eq "warning" "$(get_service_status "startup_monitor")" "the warning status is recorded"
echo "== report_and_fix: --status reports errors without repairing =="
reset_state
make_installer "$PERIODIC_SYSTEM_SCRIPT"
STATUS_ONLY=1
issues=("timer is not enabled")
status="error"
report_and_fix issues status "periodic_systems" "fixing..." "$PERIODIC_SYSTEM_SCRIPT" "" >/dev/null
_t_not_called 'ran setup_periodic' "--status never runs an installer"
_t_eq "error" "$(get_service_status "periodic_systems")" "the error status survives --status"
echo "== report_and_fix: an error runs the installer and re-verifies =="
reset_state
make_installer "$PERIODIC_SYSTEM_SCRIPT"
printf 'periodic-system-maintenance.timer\n' >"${DEV}/enabled"
issues=("timer is not enabled")
status="error"
report_and_fix issues status "periodic_systems" "fixing..." \
"$PERIODIC_SYSTEM_SCRIPT" "periodic-system-maintenance.timer" >/dev/null
_t_called 'ran setup_periodic_system' "the installer ran"
_t_eq "1" "$FIXES_APPLIED" "the fix is counted"
_t_eq "ok" "$(get_service_status "periodic_systems")" "a unit enabled after the fix is promoted to ok"
echo "== report_and_fix: a fix that does not take leaves the error standing =="
reset_state
make_installer "$PERIODIC_SYSTEM_SCRIPT"
: >"${DEV}/enabled" # the installer ran but the unit is still not enabled
issues=("timer is not enabled")
status="error"
report_and_fix issues status "periodic_systems" "fixing..." \
"$PERIODIC_SYSTEM_SCRIPT" "periodic-system-maintenance.timer" >/dev/null
_t_called 'ran setup_periodic_system' "the installer ran"
_t_eq "error" "$(get_service_status "periodic_systems")" "an unverified fix does not become ok"
echo "== report_and_fix: dry-run skips the re-verify =="
reset_state
make_installer "$PERIODIC_SYSTEM_SCRIPT"
printf 'periodic-system-maintenance.timer\n' >"${DEV}/enabled"
DRY_RUN=1
issues=("timer is not enabled")
status="error"
report_and_fix issues status "periodic_systems" "fixing..." \
"$PERIODIC_SYSTEM_SCRIPT" "periodic-system-maintenance.timer" >/dev/null
_t_not_called 'ran setup_periodic_system' "dry-run does not really install"
_t_eq "error" "$(get_service_status "periodic_systems")" "dry-run never claims a fix landed"
echo "== report_and_fix: a missing installer is a broken-self-repair error =="
reset_state
rm -f "$MIDNIGHT_SHUTDOWN_SCRIPT"
issues=("timer is not enabled")
status="error"
report_and_fix issues status "midnight_shutdown" "fixing..." \
"$MIDNIGHT_SHUTDOWN_SCRIPT" "day-specific-shutdown.timer" >/dev/null
_t_eq "1" "${#MISSING_SCRIPTS[@]}" "the missing installer is recorded"
_t_called 'logger .*MISSING REPAIR SCRIPT' "the missing installer is logged"
echo "== report_and_fix: extra arguments reach the installer =="
reset_state
make_installer "$MIDNIGHT_SHUTDOWN_SCRIPT"
issues=("timer is not enabled")
status="error"
report_and_fix issues status "midnight_shutdown" "fixing..." \
"$MIDNIGHT_SHUTDOWN_SCRIPT" "day-specific-shutdown.timer" enable >/dev/null
_t_called 'ran setup_midnight_shutdown.sh enable' "the trailing args are forwarded"
# report_and_fix rewrites `status` through its nameref; reading it back both
# asserts that and gives shellcheck the read it cannot infer through the
# nameref (SC2034). The unit is not in $DEV/enabled, so the fix cannot verify.
_t_eq "error" "$status" "an unverified fix leaves the caller's status at error"
_t_eq "1" "${#issues[@]}" "the caller's issue list is left intact"
echo "== require_root =="
reset_state
# require_root ends in `exec sudo`, which would replace this test process, so
# both branches run in a child shell against the fake sudo on PATH. EUID is
# readonly in bash and cannot be assigned, so the non-root branch is reached by
# running as the real (non-root) user and the root branch by shimming EUID
# through a wrapper that sources the lib with EUID already 0 -- which only
# `env -i` style faking can do. Instead: assert the observable behaviour of
# each branch via the fake sudo's call log.
cat >"${TEST_TMPDIR}/root_probe.sh" <<PROBE
#!/usr/bin/env bash
set -euo pipefail
. "${LIB_DIR}/services_common.sh"
require_root "\$@"
printf 'reached-past-require-root\n'
PROBE
chmod +x "${TEST_TMPDIR}/root_probe.sh"
probe_out="$("${TEST_TMPDIR}/root_probe.sh" --status 2>&1 || true)"
if [[ $EUID -ne 0 ]]; then
_t_called_in "$probe_out" "requires root privileges" "a non-root run announces the re-exec"
_t_called 'sudo -E bash .*root_probe.sh --status' "it re-execs itself under sudo with its args"
else
_t_called_in "$probe_out" "reached-past-require-root" "a root run proceeds without re-execing"
_t_not_called 'sudo -E bash' "a root run does not re-exec"
fi
echo "== hosts_pacman_hooks_installed =="
reset_state
# The two hook paths are absolute and root-owned, so this asserts the real
# machine's answer rather than a fixture: whichever way it goes, the function
# must agree with the filesystem it is reading.
if [[ -f /etc/pacman.d/hooks/10-guard-lib-unlock-all.hook &&
-f /etc/pacman.d/hooks/90-guard-lib-relock-all.hook ]]; then
if hosts_pacman_hooks_installed; then
_t_pass "reports the guard-lib hook pair as installed when both exist"
else
_t_fail "reports the guard-lib hook pair as installed when both exist"
fi
else
if hosts_pacman_hooks_installed; then
_t_fail "reports the hook pair as absent when either is missing"
else
_t_pass "reports the hook pair as absent when either is missing"
fi
fi
_t_summary

View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Tests for lib/services_report.sh — the usage text and the summary table.
#
# print_summary has four independent axes: the per-service colour mapping
# (ok/warning/error/n-a/unknown), the DRY_RUN banner, the STATUS_ONLY wording,
# and the fixes-applied vs no-fixes-applied wording. Each case below pins one.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=services_harness.sh
. "${SCRIPT_DIR}/services_harness.sh"
# shellcheck source=../services_report.sh
. "${SCRIPT_DIR}/../services_report.sh"
echo "== usage =="
usage_out="$(usage)"
_t_called_in() { # <haystack> <needle> <what>
if [[ "$1" == *"$2"* ]]; then
_t_pass "$3"
else
_t_fail "$3 (missing '$2')"
fi
}
_t_called_in "$usage_out" "--dry-run" "usage documents --dry-run"
_t_called_in "$usage_out" "--status" "usage documents --status"
_t_called_in "$usage_out" "Workout lock screen" "usage lists all 11 services"
echo "== print_summary: every status colour =="
reset_state
set_service_status "pacman_wrapper" "ok"
set_service_status "makepkg_wrapper" "warning"
set_service_status "midnight_shutdown" "error"
set_service_status "startup_monitor" "n/a"
set_service_status "periodic_systems" "skipped"
# The remaining six keys are deliberately left unset so the ":-unknown"
# default and its *) colour arm are exercised too.
ISSUES_FOUND=0
out="$(print_summary)"
_t_called_in "$out" "pacman_wrapper" "summary lists pacman_wrapper"
_t_called_in "$out" "unknown" "unset services report as unknown"
_t_called_in "$out" "All services are properly configured!" "no issues -> success line"
echo "== print_summary: dry-run banner =="
reset_state
DRY_RUN=1
ISSUES_FOUND=0
out="$(print_summary)"
_t_called_in "$out" "DRY RUN - No changes were made" "dry-run banner shown"
echo "== print_summary: status-only wording =="
reset_state
STATUS_ONLY=1
ISSUES_FOUND=3
out="$(print_summary)"
_t_called_in "$out" "Found 3 service(s) with issues" "status-only counts issues"
_t_called_in "$out" "Run without --status to fix issues" "status-only points at the fix flag"
echo "== print_summary: issues found and fixed =="
reset_state
ISSUES_FOUND=2
FIXES_APPLIED=2
out="$(print_summary)"
_t_called_in "$out" "Applied 2 fix(es)" "reports the number of fixes applied"
echo "== print_summary: issues found, nothing fixed =="
reset_state
ISSUES_FOUND=2
FIXES_APPLIED=0
out="$(print_summary)"
_t_called_in "$out" "Found 2 issue(s) but no fixes were applied" "warns when nothing was fixed"
_t_summary

View File

@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Tests for lib/services_units.sh — the three systemd timer/service checks.
#
# The unit-state half of each check is fully drivable through the systemctl
# shim ($DEV/enabled, $DEV/active). The file-existence half probes absolute
# root-owned paths (/etc/systemd/system/*.service, /usr/local/bin/*.sh) that no
# shim can redirect, so those branches take whichever way this machine is
# actually configured; the assertions below are written to hold either way.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=services_harness.sh
. "${SCRIPT_DIR}/services_harness.sh"
# shellcheck source=../services_units.sh
. "${SCRIPT_DIR}/../services_units.sh"
echo "== check_midnight_shutdown: unit enabled and active =="
reset_state
make_installer "$MIDNIGHT_SHUTDOWN_SCRIPT"
printf 'day-specific-shutdown.timer\n' >"${DEV}/enabled"
printf 'day-specific-shutdown.timer\n' >"${DEV}/active"
check_midnight_shutdown >/dev/null
_t_called 'systemctl is-enabled day-specific-shutdown.timer' "the timer's enabled state is queried"
_t_called 'systemctl is-active day-specific-shutdown.timer' "the timer's active state is queried"
echo "== check_midnight_shutdown: a disabled timer is downgraded to warning =="
reset_state
make_installer "$MIDNIGHT_SHUTDOWN_SCRIPT"
: >"${DEV}/enabled"
: >"${DEV}/active"
check_midnight_shutdown >/dev/null
# LATENT BUG, pinned deliberately rather than fixed here: the is-active check
# assigns status="warning" unconditionally, so it overwrites the "error" the
# is-enabled check set immediately above. A timer that is BOTH disabled and
# inactive therefore ends up "warning", and report_and_fix only repairs on
# "error" -- so the timer is never re-enabled. Identical in the pre-split file
# (verified against rev 6691cb26), so this split neither caused nor hides it.
# Fixing it changes enforcement behaviour and belongs in its own commit.
_t_eq "warning" "$(get_service_status "midnight_shutdown")" "disabled+inactive is (wrongly) only a warning"
_t_not_called 'ran setup_midnight_shutdown' "so the disabled timer is never repaired"
echo "== check_midnight_shutdown: --status reports without repairing =="
reset_state
make_installer "$MIDNIGHT_SHUTDOWN_SCRIPT"
STATUS_ONLY=1
: >"${DEV}/enabled"
check_midnight_shutdown >/dev/null
_t_not_called 'ran setup_midnight_shutdown' "--status never runs the setup script"
_t_eq "warning" "$(get_service_status "midnight_shutdown")" "the verdict is still recorded"
echo "== check_startup_monitor: timer disabled triggers the installer =="
reset_state
make_installer "$STARTUP_MONITOR_SCRIPT"
: >"${DEV}/enabled"
: >"${DEV}/active"
check_startup_monitor >/dev/null
# Same latent downgrade as check_midnight_shutdown above.
_t_eq "warning" "$(get_service_status "startup_monitor")" "disabled+inactive is (wrongly) only a warning"
_t_not_called 'ran setup_pc_startup_monitor' "so the disabled timer is never repaired"
echo "== check_startup_monitor: enabled but inactive is a warning, not a fix =="
reset_state
make_installer "$STARTUP_MONITOR_SCRIPT"
printf 'pc-startup-monitor.timer\n' >"${DEV}/enabled"
: >"${DEV}/active"
check_startup_monitor >/dev/null
_t_not_called 'ran setup_pc_startup_monitor' "an inactive-only timer is not reinstalled"
echo "== check_periodic_systems: every unit healthy short-circuits =="
reset_state
make_installer "$PERIODIC_SYSTEM_SCRIPT"
printf '%s\n' \
'periodic-system-maintenance.timer' \
'periodic-system-startup.service' \
'hosts-file-monitor.service' >"${DEV}/enabled"
printf '%s\n' \
'periodic-system-maintenance.timer' \
'hosts-file-monitor.service' >"${DEV}/active"
check_periodic_systems >/dev/null
# The maintenance script probe is an absolute path this test cannot stage, so
# the run is "ok" only on a machine that really has it. Assert the invariant
# that holds either way: no repair is attempted unless something reported bad.
if [[ -f /usr/local/bin/periodic-system-maintenance.sh ]]; then
_t_eq "ok" "$(get_service_status "periodic_systems")" "a fully healthy stack records ok"
_t_not_called 'ran setup_periodic_system' "a healthy stack runs no installer"
else
_t_eq "error" "$(get_service_status "periodic_systems")" "a missing maintenance script is an error"
fi
echo "== check_periodic_systems: a disabled unit is repaired =="
reset_state
make_installer "$PERIODIC_SYSTEM_SCRIPT"
: >"${DEV}/enabled"
: >"${DEV}/active"
check_periodic_systems >/dev/null
# hosts-file-monitor.service's is-active check is the last status assignment
# here, and it too downgrades unconditionally -- same bug, same pinning.
_t_eq "warning" "$(get_service_status "periodic_systems")" "a fully disabled stack is (wrongly) only a warning"
_t_not_called 'ran setup_periodic_system' "so the disabled stack is never repaired"
echo "== check_periodic_systems: startup service disabled alone still repairs =="
reset_state
make_installer "$PERIODIC_SYSTEM_SCRIPT"
printf '%s\n' \
'periodic-system-maintenance.timer' \
'hosts-file-monitor.service' >"${DEV}/enabled"
printf '%s\n' \
'periodic-system-maintenance.timer' \
'hosts-file-monitor.service' >"${DEV}/active"
check_periodic_systems >/dev/null
# This one DOES repair: hosts-file-monitor is active, so the final downgrade
# never fires and the "error" from the disabled startup service survives.
_t_called 'ran setup_periodic_system' "a disabled startup service alone triggers the repair"
_t_eq "1" "$FIXES_APPLIED" "the fix is counted"
_t_summary