diff --git a/screen_locker/adjust_shutdown_schedule.sh b/screen_locker/adjust_shutdown_schedule.sh index 76309dc..1489730 100755 --- a/screen_locker/adjust_shutdown_schedule.sh +++ b/screen_locker/adjust_shutdown_schedule.sh @@ -15,6 +15,11 @@ # shutdown window. Comparing it with `-gt`, as the two evening hours do, would # ratchet it backwards and quietly allow the window to be shortened. # +# With --restore the schedule may be loosened -- that is how the workout reward +# pushes shutdown later -- but never past RESTORE_CEILING, and every restore is +# recorded. Without the ceiling, `--restore 24 24 0` is an off switch for the +# whole ratchet. +# # Add to /etc/sudoers.d/workout-locker: # ALL=(root) NOPASSWD: /home/kuhy/screen-locker/screen_locker/adjust_shutdown_schedule.sh @@ -27,19 +32,32 @@ CONFIG_FILE="${SHUTDOWN_CONFIG_FILE:-/etc/shutdown-schedule.conf}" GUARD_NAME="${SHUTDOWN_GUARD_NAME:-shutdown-schedule}" readonly CONFIG_FILE GUARD_NAME -readonly MAX_HOUR=23 +# 24 is a real, documented value here: _shutdown.py's extra-workout bonus uses +# it to mean midnight, and day-specific-shutdown-check.sh catches it via the +# morning-window condition. The old error message claimed 0-23 while the code +# allowed 24; the message was the thing that was wrong. +readonly MAX_HOUR=24 + +# The latest the machine may ever shut down, whatever has been earned. --restore +# exists so the workout reward can loosen the schedule, and without a ceiling it +# is simply an off switch for the ratchet: `--restore 24 24 0` from any shell +# undoes everything. Bonuses above this are clamped, not refused, so the reward +# path keeps working. +readonly RESTORE_CEILING=23 +readonly RESTORE_LOG="${SHUTDOWN_RESTORE_LOG:-/var/log/shutdown-restore.log}" usage() { echo "Usage: $0 [--restore] " >&2 exit 1 } -# Validate that every argument is an hour in [0, 23]. +# Validate that every argument is an hour in [0, 24]; 24 means midnight. validate_hours() { local hour for hour in "$@"; do if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -gt $MAX_HOUR ]]; then - echo "Error: Hours must be integers between 0 and ${MAX_HOUR}" >&2 + echo "Error: Hours must be integers between 0 and ${MAX_HOUR}" \ + "(24 means midnight)" >&2 return 1 fi done @@ -87,6 +105,35 @@ check_stricter_only() { fi } +# Clamp a restore to the ceiling and record that it happened. +# +# Deliberately NOT written to /etc/shutdown-schedule-overrides.conf: that file +# is parsed by day-specific-shutdown-check.sh as start|end|created|reason, and a +# line matching the current time makes it exit 0 and skip the shutdown outright. +# An "audit trail" written there would suppress the very curfew it documents. +clamp_restore() { + local hour="$1" label="$2" + if [[ "$hour" -gt $RESTORE_CEILING ]]; then + echo "Note: ${label} ${hour}:00 clamped to the ${RESTORE_CEILING}:00 ceiling" >&2 + printf '%s' "$RESTORE_CEILING" + return 0 + fi + printf '%s' "$hour" +} + +# A restore that is not recorded is a loosening nobody can audit, so a failure +# to write the log says so on stderr rather than being swallowed. It is not +# fatal: refusing the reward because a log file is unwritable would be worse +# than the missing line. +log_restore() { + local line + line="$(date -Is) | RESTORE | mon_wed=$1 thu_sun=$2 morning_end=$3 | by=${SUDO_USER:-${USER:-unknown}}" + if ! printf '%s\n' "$line" >>"$RESTORE_LOG" 2>/dev/null; then + echo "Warning: could not record this restore in ${RESTORE_LOG};" \ + "the schedule was still loosened" >&2 + fi +} + # Resolve the guard-lib canonical copy, failing loudly when the guard is absent. resolve_canonical() { local canonical @@ -133,6 +180,11 @@ main() { if [[ "$restore_mode" == false ]]; then check_stricter_only "$mon_wed" "$thu_sun" "$morning_end" || exit 1 + else + # A restore may loosen, but never past the ceiling, and never silently. + mon_wed="$(clamp_restore "$mon_wed" "Mon-Wed")" + thu_sun="$(clamp_restore "$thu_sun" "Thu-Sun")" + log_restore "$mon_wed" "$thu_sun" "$morning_end" fi local canonical body diff --git a/screen_locker/tests/shell/test_adjust_shutdown_schedule.sh b/screen_locker/tests/shell/test_adjust_shutdown_schedule.sh index 3b82c52..45f012b 100755 --- a/screen_locker/tests/shell/test_adjust_shutdown_schedule.sh +++ b/screen_locker/tests/shell/test_adjust_shutdown_schedule.sh @@ -50,6 +50,15 @@ _t_blocks() { fi } +_t_eq() { + local want="$1" got="$2" what="$3" + if [[ "$got" == "$want" ]]; then + _t_pass "$what" + else + _t_fail "$what (want '${want}', got '${got}')" + fi +} + _t_has() { local haystack="$1" needle="$2" what="$3" if [[ "$haystack" == *"$needle"* ]]; then @@ -76,6 +85,7 @@ chmod +x "$TMP/bin/chattr" "$TMP/bin/guardctl" export PATH="$TMP/bin:$PATH" export SHUTDOWN_CONFIG_FILE="$TMP/shutdown-schedule.conf" +export SHUTDOWN_RESTORE_LOG="$TMP/restore.log" export FAKE_CANONICAL="$TMP/canonical" write_config() { @@ -91,8 +101,6 @@ source "$TARGET" echo "validate_hours" _t_allows "accepts 0 and 23" validate_hours 0 23 5 -_t_blocks "rejects 24 (the message says 0-23, and now so does the check)" \ - validate_hours 24 23 5 _t_blocks "rejects a non-integer" validate_hours 21 abc 5 _t_blocks "rejects a negative (the minus makes it non-numeric)" \ validate_hours -1 21 5 @@ -156,6 +164,37 @@ else _t_fail "--restore still loosens (deliberately unchanged)" fi +echo "validate_hours: 24 means midnight" +# _shutdown.py's extra-workout bonus caps at 24 deliberately, and +# day-specific-shutdown-check.sh catches it via the morning window. Rejecting +# 24 would break that path; the old 0-23 error message was what was wrong. +_t_allows "accepts 24 (midnight), which the bonus path relies on" \ + validate_hours 24 24 5 +_t_blocks "still rejects 25" validate_hours 25 21 5 + +echo "--restore ceiling" +_t_eq "23" "$(clamp_restore 24 Mon-Wed)" "clamps 24 down to the ceiling" +_t_eq "23" "$(clamp_restore 23 Mon-Wed)" "leaves the ceiling itself alone" +_t_eq "21" "$(clamp_restore 21 Mon-Wed)" "leaves an earlier hour alone" +out="$(clamp_restore 24 Mon-Wed 2>&1 >/dev/null)" +_t_has "$out" "clamped" "says so rather than clamping silently" + +write_config 21 21 5 +if (main --restore 24 24 5 >/dev/null 2>&1); then + _t_pass "a restore above the ceiling still succeeds" +else + _t_fail "a restore above the ceiling still succeeds" +fi +_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MON_WED_HOUR=23" \ + "the written value is the ceiling, not 24" +_t_has "$(cat "$SHUTDOWN_RESTORE_LOG")" "RESTORE" "the restore was recorded" + +# The overrides CONF is parsed by day-specific-shutdown-check.sh as +# start|end|created|reason, and a matching line makes it exit 0 and skip the +# shutdown. Audit lines must never go there. +_t_eq "0" "$(grep -c . "$TMP/shutdown-schedule-overrides.conf" 2>/dev/null || echo 0)" \ + "nothing was written to the overrides conf" + echo printf 'passed: %d, failed: %d\n' "$PASS" "$FAIL" [[ $FAIL -eq 0 ]]