From 1ebb667265440c674a58ecb7b71cbcd9e134114d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 8 May 2026 17:44:22 +0200 Subject: [PATCH] Harden runtime script deployment and enforce installer safety --- ...x-config-runtime-hardening-2026-05-08.json | 15 ++ ...x-config-runtime-hardening-2026-05-08.json | 41 +++++ .../i3blocks/activitywatch_status.sh | 59 +++++-- .../i3-configuration/i3blocks/bluetooth.sh | 83 +++++++--- .../i3-configuration/i3blocks/config | 18 +-- .../i3-configuration/i3blocks/ethernet.sh | 67 ++++++-- .../i3-configuration/i3blocks/gpu_monitor.sh | 18 ++- .../i3blocks/persist_common.sh | 55 +++++++ .../i3-configuration/i3blocks/warp_status.sh | 77 ++++++--- .../i3-configuration/i3blocks/wifi_monitor.sh | 122 ++++++++++----- .../digital_wellbeing/music_parallelism.sh | 121 +++++---------- .../pacman/install_pacman_wrapper.sh | 92 ++++++++--- .../pacman/makepkg_capped.sh | 43 ++++++ .../scripts/digital_wellbeing/pacman/mkpkg.sh | 8 + .../pacman/pacman_wrapper.sh | 22 +++ linux_configuration/scripts/lib/common.sh | 8 +- .../bin/install_usage_monitoring.sh | 16 +- .../system-maintenance/bin/usage_report.py | 38 ++++- linux_configuration/tests/__init__.py | 1 + .../tests/test_i3blocks_efficiency.sh | 55 +++++++ .../tests/test_i3blocks_persist_common.sh | 146 ++++++++++++++++++ .../tests/test_makepkg_capped.sh | 31 ++++ .../tests/test_pacman_wrapper_security.sh | 96 ++++++++++-- ...t_usage_monitoring_installer_efficiency.sh | 41 +++++ .../tests/test_usage_report_pmon_names.py | 92 +++++++++++ python_pkg/steam_backlog_enforcer/enforcer.py | 4 + .../steam_backlog_enforcer/game_install.py | 1 + .../steam-backlog-enforcer.service | 2 +- .../tests/test_enforcer.py | 19 +++ 29 files changed, 1150 insertions(+), 241 deletions(-) create mode 100644 docs/superpowers/contracts/linux-config-runtime-hardening-2026-05-08.json create mode 100644 docs/superpowers/evidence/linux-config-runtime-hardening-2026-05-08.json create mode 100755 linux_configuration/i3-configuration/i3blocks/persist_common.sh create mode 100755 linux_configuration/scripts/digital_wellbeing/pacman/makepkg_capped.sh create mode 100755 linux_configuration/scripts/digital_wellbeing/pacman/mkpkg.sh create mode 100644 linux_configuration/tests/__init__.py create mode 100755 linux_configuration/tests/test_i3blocks_persist_common.sh create mode 100755 linux_configuration/tests/test_makepkg_capped.sh create mode 100755 linux_configuration/tests/test_usage_monitoring_installer_efficiency.sh create mode 100644 linux_configuration/tests/test_usage_report_pmon_names.py diff --git a/docs/superpowers/contracts/linux-config-runtime-hardening-2026-05-08.json b/docs/superpowers/contracts/linux-config-runtime-hardening-2026-05-08.json new file mode 100644 index 0000000..673b34b --- /dev/null +++ b/docs/superpowers/contracts/linux-config-runtime-hardening-2026-05-08.json @@ -0,0 +1,15 @@ +{ + "title": "Linux config runtime hardening and deployment contract", + "objective": "Deploy and verify improved Linux scripts in runtime (not just repository state), while ensuring pacman wrapper installation handles immutable files safely and fails fast on required-file errors.", + "acceptance_criteria": [ + "Pacman wrapper exposes `--makepkg-capped` and `/usr/local/bin/makepkg_capped` + `/usr/local/bin/mkpkg` are deployed.", + "Hardened installer runs successfully and no longer reports partial-success permission failures for policy/integrity writes.", + "Optimized i3blocks scripts/config are present in ~/.config/i3blocks and active processes are running those scripts.", + "Pre-commit passes for all modified files involved in this change set." + ], + "out_of_scope": [ + "Repository-wide cleanup of unrelated legacy pre-commit failures in untouched files.", + "Functional redesign of steam_backlog_enforcer logic beyond included pending edits." + ], + "verifier": "pre-commit run --files ; bash linux_configuration/tests/test_pacman_wrapper_security.sh; runtime probes for deployed binaries/processes" +} diff --git a/docs/superpowers/evidence/linux-config-runtime-hardening-2026-05-08.json b/docs/superpowers/evidence/linux-config-runtime-hardening-2026-05-08.json new file mode 100644 index 0000000..2144a1f --- /dev/null +++ b/docs/superpowers/evidence/linux-config-runtime-hardening-2026-05-08.json @@ -0,0 +1,41 @@ +{ + "intent": "Ensure improved Linux configuration scripts are actually deployed/running and harden pacman wrapper installation against partial-success permission failures.", + "scope": [ + "linux_configuration/i3-configuration/i3blocks/*", + "linux_configuration/scripts/digital_wellbeing/pacman/*", + "linux_configuration/scripts/system-maintenance/bin/usage_report.py", + "linux_configuration/tests/*", + "python_pkg/steam_backlog_enforcer/*" + ], + "changes": [ + "Added and integrated constrained makepkg execution (`makepkg_capped`, `mkpkg`, wrapper command routing) and installer deployment paths.", + "Hardened pacman installer with strict mode and immutable-file unlock/relock flow to avoid partial-success installs.", + "Improved i3blocks runtime deployment by syncing optimized scripts/config and confirming active processes use persist helper logic.", + "Added/updated regression tests for pacman wrapper security, i3blocks persist helper, usage monitoring installer, and pmon process-name normalization." + ], + "verification": [ + { + "command": "pre-commit run --files linux_configuration/i3-configuration/i3blocks/activitywatch_status.sh linux_configuration/i3-configuration/i3blocks/bluetooth.sh linux_configuration/i3-configuration/i3blocks/config linux_configuration/i3-configuration/i3blocks/ethernet.sh linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh linux_configuration/i3-configuration/i3blocks/warp_status.sh linux_configuration/i3-configuration/i3blocks/wifi_monitor.sh linux_configuration/scripts/digital_wellbeing/music_parallelism.sh linux_configuration/scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh linux_configuration/scripts/lib/common.sh linux_configuration/scripts/system-maintenance/bin/install_usage_monitoring.sh linux_configuration/scripts/system-maintenance/bin/usage_report.py linux_configuration/tests/test_i3blocks_efficiency.sh linux_configuration/tests/test_pacman_wrapper_security.sh linux_configuration/tests/test_i3blocks_persist_common.sh python_pkg/steam_backlog_enforcer/enforcer.py python_pkg/steam_backlog_enforcer/game_install.py python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service python_pkg/steam_backlog_enforcer/tests/test_enforcer.py linux_configuration/i3-configuration/i3blocks/persist_common.sh linux_configuration/scripts/digital_wellbeing/pacman/makepkg_capped.sh linux_configuration/scripts/digital_wellbeing/pacman/mkpkg.sh linux_configuration/tests/__init__.py linux_configuration/tests/test_makepkg_capped.sh linux_configuration/tests/test_usage_monitoring_installer_efficiency.sh linux_configuration/tests/test_usage_report_pmon_names.py", + "result": "pass", + "evidence": "All hooks passed including no-polling-antipatterns, ruff, shellcheck, and leak checks." + }, + { + "command": "bash linux_configuration/tests/test_pacman_wrapper_security.sh", + "result": "pass", + "evidence": "All 19 security/integration checks passed, including strict installer mode and immutable-file handling markers." + }, + { + "command": "bash linux_configuration/scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh", + "result": "pass", + "evidence": "Installer completed without prior Operation not permitted failures; deployed binaries and wrapper help verified." + } + ], + "risks": [ + "Installer now fails fast for missing required source files, which may stop previously permissive installs.", + "Live i3blocks config/script sync may diverge from user-local manual tweaks if reapplied blindly." + ], + "rollback": [ + "Revert commit and rerun installer to restore previous wrapper behavior.", + "Restore i3blocks config from generated backup in ~/.config/i3blocks/config.bak. and restart i3blocks." + ] +} diff --git a/linux_configuration/i3-configuration/i3blocks/activitywatch_status.sh b/linux_configuration/i3-configuration/i3blocks/activitywatch_status.sh index b4f2bf7..088d6ff 100755 --- a/linux_configuration/i3-configuration/i3blocks/activitywatch_status.sh +++ b/linux_configuration/i3-configuration/i3blocks/activitywatch_status.sh @@ -3,6 +3,11 @@ set -euo pipefail +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' +# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh +source "$SCRIPT_DIR/persist_common.sh" + check_installed() { command -v aw-qt > /dev/null 2>&1 || command -v aw-server > /dev/null 2>&1 } @@ -21,16 +26,46 @@ check_running() { return 1 } -if ! check_installed; then - echo "AW uninstalled" - echo - echo "#FF0000" -elif check_running; then - echo "AW on" - echo - echo "#00FF00" -else - echo "AW off" - echo - echo "#FF0000" +emit() { + local state + if ! check_installed; then + state='uninstalled' + elif check_running; then + state='on' + else + state='off' + fi + + if ! i3blocks_update_if_changed_key "activitywatch_state" "$state"; then + return 0 + fi + + if [[ $state == 'uninstalled' ]]; then + echo "AW uninstalled" + echo + echo "#FF0000" + elif [[ $state == 'on' ]]; then + echo "AW on" + echo + echo "#00FF00" + else + echo "AW off" + echo + echo "#FF0000" + fi +} + +is_persist_mode() { + [[ ${BLOCK_INTERVAL:-} == "persist" ]] +} + +emit + +if is_persist_mode; then + # Intentionally calm heartbeat in persist mode: process-table event streams can + # be extremely noisy and cause unnecessary churn. + while true; do + sleep 20 + emit + done fi diff --git a/linux_configuration/i3-configuration/i3blocks/bluetooth.sh b/linux_configuration/i3-configuration/i3blocks/bluetooth.sh index 24e64df..8b3538b 100755 --- a/linux_configuration/i3-configuration/i3blocks/bluetooth.sh +++ b/linux_configuration/i3-configuration/i3blocks/bluetooth.sh @@ -3,25 +3,70 @@ set -euo pipefail -bluetooth_info=$(bluetoothctl info 2> /dev/null) || bluetooth_info='' +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' +# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh +source "$SCRIPT_DIR/persist_common.sh" -connected='no' -device='' -while IFS= read -r line; do - case $line in - *'Connected: yes') - connected='yes' - ;; - *'Alias: '*) - device=${line#*Alias: } - ;; - esac -done <<< "$bluetooth_info" +get_bluetooth_info() { + local info + info=$(bluetoothctl info 2> /dev/null) || info='' + printf '%s\n' "$info" +} -if [[ $connected == yes && -n $device ]]; then - echo " $device" - echo - echo "#50FA7B" -else - echo " Disconnected" +emit() { + local bluetooth_info connected device line state_key + bluetooth_info=$(get_bluetooth_info) + + connected='no' + device='' + while IFS= read -r line; do + case $line in + *'Connected: yes') + connected='yes' + ;; + *'Alias: '*) + device=${line#*Alias: } + ;; + esac + done <<< "$bluetooth_info" + + state_key="$connected|$device" + if ! i3blocks_update_if_changed_key "bluetooth_state" "$state_key"; then + return 0 + fi + + if [[ $connected == yes && -n $device ]]; then + echo " $device" + echo + echo "#50FA7B" + else + echo " Disconnected" + fi +} + +is_persist_mode() { + [[ ${BLOCK_INTERVAL:-} == "persist" ]] +} + +emit_throttled() { + if ! i3blocks_should_emit_by_interval_key "bluetooth_emit" "$EMIT_MIN_INTERVAL_S"; then + return 0 + fi + emit +} + +EMIT_MIN_INTERVAL_S=2 + +emit + +if is_persist_mode; then + # React to BlueZ D-Bus signals instead of polling. + if command -v dbus-monitor > /dev/null 2>&1; then + dbus-monitor --system "type='signal',sender='org.bluez'" 2> /dev/null | + while read -r line; do + [[ $line == *"PropertiesChanged"* ]] || continue + emit_throttled + done + fi fi diff --git a/linux_configuration/i3-configuration/i3blocks/config b/linux_configuration/i3-configuration/i3blocks/config index ef90ebe..7ae9484 100644 --- a/linux_configuration/i3-configuration/i3blocks/config +++ b/linux_configuration/i3-configuration/i3blocks/config @@ -1,6 +1,6 @@ [cpu_monitor] command=~/.config/i3blocks/cpu_monitor.sh -interval=5 +interval=10 markup=pango @@ -12,13 +12,13 @@ markup=pango [motherboard_temperature] command=~/.config/i3blocks/motherboard_temp.sh -interval=5 +interval=30 markup=pango [memory] command=~/.config/i3blocks/memory.sh -interval=5 +interval=30 color=#50FA7B @@ -39,25 +39,25 @@ markup=pango [bluetooth] command=~/.config/i3blocks/bluetooth.sh -interval=5 +interval=persist color=#FFFFFF [battery] command=~/.config/i3blocks/battery_status.sh -interval=5 +interval=60 markup=pango [ethernet] command=~/.config/i3blocks/ethernet.sh -interval=10 +interval=persist color=#FFFFFF [wifi] command=~/.config/i3blocks/wifi_monitor.sh -interval=10 +interval=persist color=#FFFFFF @@ -69,12 +69,12 @@ color=#FFFFFF [warp] command=~/.config/i3blocks/warp_status.sh -interval=60 +interval=persist [activitywatch] command=~/.config/i3blocks/activitywatch_status.sh -interval=10 +interval=persist color=#FFFFFF diff --git a/linux_configuration/i3-configuration/i3blocks/ethernet.sh b/linux_configuration/i3-configuration/i3blocks/ethernet.sh index f7ff5ea..91232ed 100755 --- a/linux_configuration/i3-configuration/i3blocks/ethernet.sh +++ b/linux_configuration/i3-configuration/i3blocks/ethernet.sh @@ -3,6 +3,11 @@ set -euo pipefail +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' +# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh +source "$SCRIPT_DIR/persist_common.sh" + find_ethernet_interface() { local iface_path iface for iface_path in /sys/class/net/*; do @@ -16,20 +21,56 @@ find_ethernet_interface() { return 1 } -iface=$(find_ethernet_interface) || { - printf ' down\n' - exit 0 +emit() { + local iface state addr_output output_line + iface=$(find_ethernet_interface) || { + output_line=' down' + if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then + printf '%s\n' "$output_line" + fi + return 0 + } + + read -r state < "/sys/class/net/${iface}/operstate" + if [[ $state != up ]]; then + output_line=' down' + if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then + printf '%s\n' "$output_line" + fi + return 0 + fi + + addr_output=$(ip -o -4 addr show dev "$iface" scope global 2> /dev/null) || addr_output='' + if [[ $addr_output =~ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+) ]]; then + output_line=" ${BASH_REMATCH[1]}" + else + output_line=' down' + fi + + if i3blocks_update_if_changed_key "ethernet_output" "$output_line"; then + printf '%s\n' "$output_line" + fi } -read -r state < "/sys/class/net/${iface}/operstate" -if [[ $state != up ]]; then - printf ' down\n' - exit 0 -fi +is_persist_mode() { + [[ ${BLOCK_INTERVAL:-} == "persist" ]] +} -addr_output=$(ip -o -4 addr show dev "$iface" scope global 2> /dev/null) || addr_output='' -if [[ $addr_output =~ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+) ]]; then - printf ' %s\n' "${BASH_REMATCH[1]}" -else - printf ' down\n' +emit_throttled() { + if ! i3blocks_should_emit_by_interval_key "ethernet_emit" "$EMIT_MIN_INTERVAL_S"; then + return 0 + fi + emit +} + +EMIT_MIN_INTERVAL_S=2 + +emit + +if is_persist_mode; then + ip monitor link address route 2> /dev/null | + while read -r line; do + [[ $line == *"eth"* || $line == *"en"* || $line == *"inet "* ]] || continue + emit_throttled + done fi diff --git a/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh b/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh index 6c7ffb4..a46ccbc 100755 --- a/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh +++ b/linux_configuration/i3-configuration/i3blocks/gpu_monitor.sh @@ -12,6 +12,11 @@ set -u +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' +# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh +source "$SCRIPT_DIR/persist_common.sh" + # Nerd Font glyph: display / desktop icon (U+F108). ICON=$'\uf108' @@ -30,6 +35,15 @@ emit() { "$color" "$ICON" "$temp" "$load" } +emit_if_changed() { + local temp=$1 load=$2 + if ! i3blocks_update_if_changed_key "gpu_metric" "$temp|$load"; then + return 0 + fi + emit "$temp" "$load" +} + + # Prefer NVIDIA if present (persist via --loop). if command -v nvidia-smi > /dev/null 2>&1; then # One child process for the lifetime of i3blocks; emits CSV every 5s. @@ -44,7 +58,7 @@ if command -v nvidia-smi > /dev/null 2>&1; then load=${load## } load=${load%% } [[ -z $temp || -z $load ]] && continue - emit "$temp" "$load" + emit_if_changed "$temp" "$load" done exit 0 fi @@ -73,7 +87,7 @@ if [[ -n $amdgpu ]]; then break } done - emit "$temp" "$load" + emit_if_changed "$temp" "$load" exit 0 fi diff --git a/linux_configuration/i3-configuration/i3blocks/persist_common.sh b/linux_configuration/i3-configuration/i3blocks/persist_common.sh new file mode 100755 index 0000000..4aded18 --- /dev/null +++ b/linux_configuration/i3-configuration/i3blocks/persist_common.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Shared helpers for persist-mode i3blocks scripts. + +set -u + +declare -gA I3BLOCKS_LAST_TS=() +declare -gA I3BLOCKS_LAST_STATE=() + +# Return current epoch seconds using bash builtin time formatting. +i3blocks_now_ts() { + if [[ -n ${I3BLOCKS_TEST_NOW_TS:-} ]]; then + printf '%s\n' "$I3BLOCKS_TEST_NOW_TS" + return 0 + fi + printf '%(%s)T' -1 +} + +# Return 0 when enough time elapsed since last emit timestamp for key. +# Usage: if i3blocks_should_emit_by_interval_key "wifi" 2; then ... +i3blocks_should_emit_by_interval_key() { + local key=$1 + local min_interval_s=$2 + local now last + + now=$(i3blocks_now_ts) + last=${I3BLOCKS_LAST_TS[$key]:-0} + + if (( now - last < min_interval_s )); then + return 1 + fi + + I3BLOCKS_LAST_TS[$key]=$now + return 0 +} + +# Return 0 when new state differs from current value for key, then update. +# Usage: if i3blocks_update_if_changed_key "wifi_output" "$line"; then emit; fi +i3blocks_update_if_changed_key() { + local key=$1 + local new_state=$2 + local current_state + + if [[ -v I3BLOCKS_LAST_STATE[$key] ]]; then + current_state=${I3BLOCKS_LAST_STATE[$key]} + else + current_state='' + fi + + if [[ -v I3BLOCKS_LAST_STATE[$key] && $current_state == "$new_state" ]]; then + return 1 + fi + + I3BLOCKS_LAST_STATE[$key]=$new_state + return 0 +} diff --git a/linux_configuration/i3-configuration/i3blocks/warp_status.sh b/linux_configuration/i3-configuration/i3blocks/warp_status.sh index d649822..4cc573c 100755 --- a/linux_configuration/i3-configuration/i3blocks/warp_status.sh +++ b/linux_configuration/i3-configuration/i3blocks/warp_status.sh @@ -3,30 +3,67 @@ set -euo pipefail +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' +# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh +source "$SCRIPT_DIR/persist_common.sh" + if ! command -v warp-cli > /dev/null 2>&1; then echo " N/A" exit 0 fi -status='' -while IFS= read -r line; do - case $line in - 'Status update: '*) - status=${line#Status update: } - ;; - esac -done < <(warp-cli status 2> /dev/null) +is_persist_mode() { + [[ ${BLOCK_INTERVAL:-} == "persist" ]] +} -if [[ $status == Connected ]]; then - echo "🔒 !!! WARP CONNECTED !!!" - echo - echo "#FFFF00" -elif [[ $status == Disconnected ]]; then - echo "WARP disconnected" - echo - echo "#00FF00" -else - echo "⚠️ ! WARP unknown !" - echo - echo "#FF0000" +read_status() { + local status line + status='' + while IFS= read -r line; do + case $line in + 'Status update: '*) + status=${line#Status update: } + ;; + esac + done < <(warp-cli status 2> /dev/null) + printf '%s\n' "$status" +} + +emit_status() { + local status=$1 + if [[ $status == Connected ]]; then + echo "🔒 !!! WARP CONNECTED !!!" + echo + echo "#FFFF00" + elif [[ $status == Disconnected ]]; then + echo "WARP disconnected" + echo + echo "#00FF00" + else + echo "⚠️ ! WARP unknown !" + echo + echo "#FF0000" + fi +} + +emit_if_changed() { + local status=$1 + if ! i3blocks_update_if_changed_key "warp_status" "$status"; then + return 0 + fi + emit_status "$status" +} + +current_status=$(read_status) +emit_status "$current_status" +if is_persist_mode; then + i3blocks_update_if_changed_key "warp_status" "$current_status" >/dev/null || true +fi +if is_persist_mode; then + while true; do + sleep 60 + current_status=$(read_status) + emit_if_changed "$current_status" + done fi diff --git a/linux_configuration/i3-configuration/i3blocks/wifi_monitor.sh b/linux_configuration/i3-configuration/i3blocks/wifi_monitor.sh index 65ed724..009ebe0 100755 --- a/linux_configuration/i3-configuration/i3blocks/wifi_monitor.sh +++ b/linux_configuration/i3-configuration/i3blocks/wifi_monitor.sh @@ -3,6 +3,11 @@ set -euo pipefail +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +[[ $SCRIPT_DIR == "${BASH_SOURCE[0]}" ]] && SCRIPT_DIR='.' +# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh +source "$SCRIPT_DIR/persist_common.sh" + find_wifi_interface() { local line while IFS= read -r line; do @@ -16,47 +21,84 @@ find_wifi_interface() { return 1 } -wifi_interface=$(find_wifi_interface) || { - echo " down" - exit 0 +emit() { + local wifi_interface ssid signal ip_address line fields i output_line + + wifi_interface=$(find_wifi_interface) || { + output_line=' down' + if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then + echo "$output_line" + fi + return 0 + } + + ssid='' + signal='' + while IFS= read -r line; do + case $line in + 'SSID: '*) + ssid=${line#SSID: } + ;; + 'signal: '*) + signal=${line#signal: } + signal=${signal% dBm} + ;; + 'Not connected.'*) + ssid='' + ;; + esac + done < <(iw dev "$wifi_interface" link 2> /dev/null) + + if [[ -z $ssid ]]; then + output_line=' down' + if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then + echo "$output_line" + fi + return 0 + fi + + ip_address='' + while IFS= read -r line; do + [[ $line == *' inet '* ]] || continue + read -r -a fields <<< "$line" + for ((i = 0; i < ${#fields[@]}; i++)); do + if [[ ${fields[i]} == inet && $((i + 1)) -lt ${#fields[@]} ]]; then + ip_address=${fields[i + 1]%%/*} + break 2 + fi + done + done < <(ip -o -4 addr show dev "$wifi_interface" scope global 2> /dev/null) + + if [[ -n $ip_address ]]; then + output_line=" $ssid ($signal dBm) $ip_address" + else + output_line=" $ssid ($signal dBm)" + fi + + if i3blocks_update_if_changed_key "wifi_output" "$output_line"; then + echo "$output_line" + fi } -ssid='' -signal='' -while IFS= read -r line; do - case $line in - 'SSID: '*) - ssid=${line#SSID: } - ;; - 'signal: '*) - signal=${line#signal: } - signal=${signal% dBm} - ;; - 'Not connected.'*) - ssid='' - ;; - esac -done < <(iw dev "$wifi_interface" link 2> /dev/null) +is_persist_mode() { + [[ ${BLOCK_INTERVAL:-} == "persist" ]] +} -if [[ -z $ssid ]]; then - echo " down" - exit 0 -fi - -ip_address='' -while IFS= read -r line; do - [[ $line == *' inet '* ]] || continue - read -r -a fields <<< "$line" - for ((i = 0; i < ${#fields[@]}; i++)); do - if [[ ${fields[i]} == inet && $((i + 1)) -lt ${#fields[@]} ]]; then - ip_address=${fields[i + 1]%%/*} - break 2 - fi - done -done < <(ip -o -4 addr show dev "$wifi_interface" scope global 2> /dev/null) - -if [[ -n $ip_address ]]; then - echo " $ssid ($signal dBm) $ip_address" -else - echo " $ssid ($signal dBm)" +emit_throttled() { + if ! i3blocks_should_emit_by_interval_key "wifi_emit" "$EMIT_MIN_INTERVAL_S"; then + return 0 + fi + emit +} + +EMIT_MIN_INTERVAL_S=2 + +emit + +if is_persist_mode; then + ip monitor link address route 2> /dev/null | + while read -r line; do + [[ $line == *"wlan"* || $line == *"wl"* || $line == *"inet "* ]] || continue + emit_throttled + done fi diff --git a/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh b/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh index 4c05131..bf6d8c8 100755 --- a/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh +++ b/linux_configuration/scripts/digital_wellbeing/music_parallelism.sh @@ -26,9 +26,10 @@ fi LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/music-parallelism" mkdir -p "$LOG_DIR" 2> /dev/null || true export LOG_FILE="$LOG_DIR/music-parallelism.log" -CHECK_INTERVAL=3 -FAST_CHECK_INTERVAL=2 -IDLE_CHECK_INTERVAL=10 +CHECK_INTERVAL=15 +FAST_CHECK_INTERVAL=5 +IDLE_CHECK_INTERVAL=30 +ENFORCEMENT_COOLDOWN=20 # Override focus apps with extended list for this script FOCUS_APPS_WINDOWS=( @@ -88,14 +89,9 @@ find_music_services() { printf -v music_pattern '%s|' "${MUSIC_SERVICES[@]}" music_pattern="${music_pattern%|}" # strip trailing | - # Check processes (single fork) - local matching_services - if matching_services=$(pgrep -i -f "$music_pattern" 2>/dev/null); then - while read -r pid; do - local proc_name - proc_name=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown") - found_services+=("$proc_name (process)") - done <<< "$matching_services" + # Check processes (single fork, no per-PID helpers) + if pgrep -i -f "$music_pattern" &> /dev/null; then + found_services+=("music process") fi # Check windows (use optimized is_focus_app_running logic: single xdotool regex call) @@ -118,71 +114,26 @@ find_music_services() { # Kill music services kill_music_services() { local killed=false + local process_pattern='youtube-music|spotify|tidal|deezer|Amazon Music|amazon music' + local window_pattern='YouTube Music|music\.youtube\.com|music\.apple\.com|soundcloud\.com|pandora\.com|deezer\.com|tidal\.com' - # Kill YouTube Music browser tabs - # YouTube Music runs in browser, so we need to close specific tabs - # We use xdotool to find and close windows with "YouTube Music" or "music.youtube.com" + # Close browser tabs for web-based music services via one xdotool search if command -v xdotool &> /dev/null; then - # Find windows with YouTube Music in title - local yt_music_windows - yt_music_windows=$(xdotool search --name "YouTube Music" 2> /dev/null || true) - for wid in $yt_music_windows; do - if [[ -n $wid ]]; then - # Get window name for logging - local wname - wname=$(xdotool getwindowname "$wid" 2> /dev/null || echo "unknown") - # Only close if it's YouTube Music, not regular YouTube - if [[ $wname == *"YouTube Music"* ]] || [[ $wname == *"music.youtube.com"* ]]; then - log_message "Closing YouTube Music window: $wname (ID: $wid)" - xdotool windowclose "$wid" 2> /dev/null || true - killed=true - fi - fi - done - fi - - # Kill YouTube Music Electron app - if pgrep -f "youtube-music" &> /dev/null; then - log_message "Killing YouTube Music app" - pkill -9 -f "youtube-music" 2> /dev/null || true - killed=true - fi - - # Kill Spotify - if pgrep -x "spotify" &> /dev/null; then - log_message "Killing Spotify" - pkill -9 -x "spotify" 2> /dev/null || true - killed=true - fi - - # Kill other music streaming app processes - local music_processes=("tidal" "deezer" "Amazon Music") - for proc in "${music_processes[@]}"; do - if pgrep -i -f "$proc" &> /dev/null; then - log_message "Killing $proc" - pkill -9 -i -f "$proc" 2> /dev/null || true + local windows wid + windows=$(xdotool search --name "$window_pattern" 2> /dev/null || true) + for wid in $windows; do + [[ -n $wid ]] || continue + xdotool windowclose "$wid" 2> /dev/null || true killed=true - fi - done - - # Close browser tabs for web-based music services - if command -v xdotool &> /dev/null; then - local web_music_patterns=("music.apple.com" "soundcloud.com" "pandora.com" "deezer.com" "tidal.com") - for pattern in "${web_music_patterns[@]}"; do - local windows - windows=$(xdotool search --name "$pattern" 2> /dev/null || true) - for wid in $windows; do - if [[ -n $wid ]]; then - local wname - wname=$(xdotool getwindowname "$wid" 2> /dev/null || echo "unknown") - log_message "Closing music service window: $wname (ID: $wid)" - xdotool windowclose "$wid" 2> /dev/null || true - killed=true - fi - done done fi + # Kill app processes with one regex-based pkill + if pgrep -i -f "$process_pattern" &> /dev/null; then + pkill -9 -i -f "$process_pattern" 2> /dev/null || true + killed=true + fi + if $killed; then return 0 fi @@ -206,24 +157,30 @@ notify_user() { # When focus app active: checks every 0.5s. When idle: checks every 3s. Reduces fork overhead. # OPTIMIZATION: Single batched pgrep call instead of multiple separate calls instant_monitor_loop() { + local next_enforcement_ts=0 + local current_ts=0 + local focus_app="" + log_message "=== Music Parallelism INSTANT Monitor Started ===" log_message "Focus apps (windows): ${FOCUS_APPS_WINDOWS[*]}" log_message "Focus apps (processes): ${FOCUS_APPS_PROCESSES[*]}" - log_message "Polling: 0.5s when focus app active, 3s when idle (optimized for lower fork overhead)" + log_message "Polling: ${FAST_CHECK_INTERVAL}s active, ${IDLE_CHECK_INTERVAL}s idle, ${ENFORCEMENT_COOLDOWN}s enforcement cooldown" while true; do - # Only check if focus app is running (uses optimized is_focus_app_running from common.sh) - if is_focus_app_running &> /dev/null; then - # OPTIMIZATION: Single pgrep call with regex instead of multiple calls - # Kill youtube-music OR spotify with one command (use pkill to avoid pipe fork) - if pgrep -i -f "youtube-music|spotify" &> /dev/null; then - pkill -i -f "youtube-music|spotify" 2> /dev/null || true - log_message "INSTANT KILL: Music services terminated" - notify-send -u normal -t 2000 "🎵 Music killed" "Focus mode active" 2> /dev/null || true + if focus_app=$(is_focus_app_running 2> /dev/null); then + current_ts=$(get_timestamp) + if (( current_ts >= next_enforcement_ts )); then + if find_music_services > /dev/null 2>&1; then + if kill_music_services; then + notify_user "$focus_app" + log_message "INSTANT KILL: Music services terminated" + fi + fi + next_enforcement_ts=$((current_ts + ENFORCEMENT_COOLDOWN)) fi - sleep "$FAST_CHECK_INTERVAL" # High-frequency check while focus app is active + sleep "$FAST_CHECK_INTERVAL" else - # No focus app detected: use longer sleep to reduce fork overhead significantly + next_enforcement_ts=0 sleep "$IDLE_CHECK_INTERVAL" fi done @@ -328,7 +285,7 @@ show_usage() { echo "" echo "Commands:" echo " monitor - Start monitoring (default, checks every ${CHECK_INTERVAL}s)" - echo " instant - Instant monitoring (checks every 0.5s for immediate kill)" + echo " instant - Instant monitoring (${FAST_CHECK_INTERVAL}s active / ${IDLE_CHECK_INTERVAL}s idle)" echo " status - Show current status of focus apps and music services" echo " kill - Immediately kill all music services" echo " help - Show this help message" diff --git a/linux_configuration/scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh b/linux_configuration/scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh index 48fe837..dcde07e 100755 --- a/linux_configuration/scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh +++ b/linux_configuration/scripts/digital_wellbeing/pacman/install_pacman_wrapper.sh @@ -1,6 +1,8 @@ #!/bin/bash # filepath: /home/kuhy/linux-configuration/scripts/install_pacman_wrapper.sh +set -euo pipefail + # Auto-sudo functionality if [ "$EUID" -ne 0 ]; then echo "Executing with sudo..." @@ -22,12 +24,16 @@ WORDS_SOURCE="$(dirname "$0")/words.txt" BLOCKED_SOURCE="$(dirname "$0")/pacman_blocked_keywords.txt" WHITELIST_SOURCE="$(dirname "$0")/pacman_whitelist.txt" GREYLIST_SOURCE="$(dirname "$0")/pacman_greylist.txt" +MAKEPKG_CAPPED_SOURCE="$(dirname "$0")/makepkg_capped.sh" +MKPKG_SOURCE="$(dirname "$0")/mkpkg.sh" INSTALL_DIR="/usr/local/bin" WRAPPER_DEST="${INSTALL_DIR}/pacman_wrapper" WORDS_DEST="${INSTALL_DIR}/words.txt" BLOCKED_DEST="${INSTALL_DIR}/pacman_blocked_keywords.txt" WHITELIST_DEST="${INSTALL_DIR}/pacman_whitelist.txt" GREYLIST_DEST="${INSTALL_DIR}/pacman_greylist.txt" +MAKEPKG_CAPPED_DEST="${INSTALL_DIR}/makepkg_capped" +MKPKG_DEST="${INSTALL_DIR}/mkpkg" INTEGRITY_DIR="/var/lib/pacman-wrapper" INTEGRITY_FILE="${INTEGRITY_DIR}/policy.sha256" LEECHBLOCK_INSTALLER_SOURCE="$(dirname "$0")/../install_leechblock.sh" @@ -41,6 +47,57 @@ LEECHBLOCK_SEEDER_DEST="${LEECHBLOCK_INSTALL_DIR}/seed_leechblock_storage.js" VBOX_ENFORCE_SOURCE="$(dirname "$0")/../virtualbox/enforce_vbox_hosts.sh" VBOX_INSTALL_DIR="/usr/local/share/digital_wellbeing/virtualbox" VBOX_ENFORCE_DEST="${VBOX_INSTALL_DIR}/enforce_vbox_hosts.sh" + +declare -a RELock_FILES=() + +is_immutable_file() { + local file_path="$1" + [[ -e "$file_path" ]] || return 1 + [[ $(lsattr -d "$file_path" 2>/dev/null | awk '{print $1}') == *i* ]] +} + +unlock_immutable_file_if_needed() { + local file_path="$1" + if ! command -v chattr >/dev/null 2>&1; then + return 0 + fi + if is_immutable_file "$file_path"; then + chattr -i "$file_path" + RELock_FILES+=("$file_path") + fi +} + +relock_files_on_exit() { + if ! command -v chattr >/dev/null 2>&1; then + return + fi + for file_path in "${RELock_FILES[@]}"; do + [[ -e "$file_path" ]] || continue + chattr +i "$file_path" 2>/dev/null || true + done +} + +copy_managed_file() { + local source_file="$1" + local dest_file="$2" + local required="$3" + local label="$4" + + if [[ ! -f "$source_file" ]]; then + if [[ "$required" == "required" ]]; then + echo -e "${RED}Error:${NC} Missing required ${label} at ${source_file}" >&2 + exit 1 + fi + echo -e "${YELLOW}Warning:${NC} Missing ${label} at ${source_file}" >&2 + return + fi + + unlock_immutable_file_if_needed "$dest_file" + cp "$source_file" "$dest_file" +} + +trap relock_files_on_exit EXIT + # Check if script is run as root if [ "$EUID" -ne 0 ]; then echo -e "${RED}Please run as root${NC}" @@ -57,26 +114,16 @@ echo -e "${CYAN}Installing pacman wrapper...${NC}" # Install the wrapper script echo -e "${BLUE}Copying wrapper script to ${WRAPPER_DEST}...${NC}" -cp "$WRAPPER_SOURCE" "$WRAPPER_DEST" -cp "$WORDS_SOURCE" "$WORDS_DEST" -if [ -f "$BLOCKED_SOURCE" ]; then - cp "$BLOCKED_SOURCE" "$BLOCKED_DEST" -else - echo -e "${YELLOW}Warning:${NC} Missing blocked keywords source at ${BLOCKED_SOURCE}${NC}" -fi - -if [ -f "$WHITELIST_SOURCE" ]; then - cp "$WHITELIST_SOURCE" "$WHITELIST_DEST" -else - echo -e "${YELLOW}Warning:${NC} Missing whitelist source at ${WHITELIST_SOURCE}${NC}" -fi - -if [ -f "$GREYLIST_SOURCE" ]; then - cp "$GREYLIST_SOURCE" "$GREYLIST_DEST" -else - echo -e "${YELLOW}Warning:${NC} Missing greylist source at ${GREYLIST_SOURCE}${NC}" -fi +copy_managed_file "$WRAPPER_SOURCE" "$WRAPPER_DEST" required "wrapper script" +copy_managed_file "$WORDS_SOURCE" "$WORDS_DEST" required "words list" +copy_managed_file "$BLOCKED_SOURCE" "$BLOCKED_DEST" required "blocked keywords list" +copy_managed_file "$WHITELIST_SOURCE" "$WHITELIST_DEST" optional "whitelist" +copy_managed_file "$GREYLIST_SOURCE" "$GREYLIST_DEST" required "greylist" chmod +x "$WRAPPER_DEST" +copy_managed_file "$MAKEPKG_CAPPED_SOURCE" "$MAKEPKG_CAPPED_DEST" required "makepkg capped wrapper" +chmod +x "$MAKEPKG_CAPPED_DEST" +copy_managed_file "$MKPKG_SOURCE" "$MKPKG_DEST" required "mkpkg helper" +chmod +x "$MKPKG_DEST" chmod 644 "$WORDS_DEST" "$BLOCKED_DEST" "$WHITELIST_DEST" "$GREYLIST_DEST" 2> /dev/null || true # Automatically use symbolic link installation method @@ -97,6 +144,7 @@ chmod 755 "$INTEGRITY_DIR" # Generate checksums of policy files for integrity verification echo -e "${BLUE}Generating integrity checksums for policy files...${NC}" +unlock_immutable_file_if_needed "$INTEGRITY_FILE" # Ensure all critical policy files exist before checksumming missing_files=() @@ -181,6 +229,12 @@ echo -e "${BLUE}Creating symbolic link...${NC}" ln -sf "$WRAPPER_DEST" /usr/bin/pacman echo -e "${GREEN}Installation complete!${NC}" echo -e "Pacman is now wrapped. The original pacman is available at ${CYAN}/usr/bin/pacman.orig${NC}" +if [ -f "$MAKEPKG_CAPPED_DEST" ]; then + echo -e "Run constrained package builds with: ${CYAN}pacman --makepkg-capped ${NC}" +fi +if [ -f "$MKPKG_DEST" ]; then + echo -e "Shortcut available: ${CYAN}mkpkg ${NC}" +fi echo -e "${CYAN}Policy files are now protected with immutable attributes.${NC}" if [ -f "$VBOX_ENFORCE_DEST" ]; then echo -e "${CYAN}VirtualBox VMs will automatically be configured to use host's /etc/hosts.${NC}" diff --git a/linux_configuration/scripts/digital_wellbeing/pacman/makepkg_capped.sh b/linux_configuration/scripts/digital_wellbeing/pacman/makepkg_capped.sh new file mode 100755 index 0000000..86d440b --- /dev/null +++ b/linux_configuration/scripts/digital_wellbeing/pacman/makepkg_capped.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Run makepkg inside a constrained user scope to protect desktop responsiveness. + +set -euo pipefail + +CPU_QUOTA="${MAKEPKG_CPU_QUOTA:-70%}" +MEMORY_MAX="${MAKEPKG_MEMORY_MAX:-12G}" +TASKS_MAX="${MAKEPKG_TASKS_MAX:-2048}" +NICE_LEVEL="${MAKEPKG_NICE_LEVEL:-10}" +IONICE_CLASS="${MAKEPKG_IONICE_CLASS:-2}" +IONICE_LEVEL="${MAKEPKG_IONICE_LEVEL:-7}" + +if ! command -v makepkg >/dev/null 2>&1; then + echo "makepkg_capped: makepkg not found in PATH" >&2 + exit 127 +fi + +run_makepkg_fallback() { + exec ionice -c "$IONICE_CLASS" -n "$IONICE_LEVEL" \ + nice -n "$NICE_LEVEL" \ + makepkg "$@" +} + +if ! command -v systemd-run >/dev/null 2>&1; then + echo "makepkg_capped: systemd-run unavailable, falling back to local limits" >&2 + run_makepkg_fallback "$@" +fi + +# Keep builds in the user manager scope. If this fails (no user manager), +# gracefully fall back to ionice+nice only. +if ! systemd-run --user --scope --quiet true 2>/dev/null; then + echo "makepkg_capped: user systemd scope unavailable, falling back" >&2 + run_makepkg_fallback "$@" +fi + +exec systemd-run --user --scope --quiet --same-dir --collect \ + -p "CPUQuota=${CPU_QUOTA}" \ + -p "MemoryMax=${MEMORY_MAX}" \ + -p "MemorySwapMax=0" \ + -p "TasksMax=${TASKS_MAX}" \ + ionice -c "$IONICE_CLASS" -n "$IONICE_LEVEL" \ + nice -n "$NICE_LEVEL" \ + makepkg "$@" diff --git a/linux_configuration/scripts/digital_wellbeing/pacman/mkpkg.sh b/linux_configuration/scripts/digital_wellbeing/pacman/mkpkg.sh new file mode 100755 index 0000000..46c9d49 --- /dev/null +++ b/linux_configuration/scripts/digital_wellbeing/pacman/mkpkg.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Convenience wrapper for constrained Arch package builds. + +set -euo pipefail + +PACMAN_WRAPPER_BIN="/usr/bin/pacman" + +exec "$PACMAN_WRAPPER_BIN" --makepkg-capped "$@" diff --git a/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh b/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh index 2ea2c4f..2e4da33 100755 --- a/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh +++ b/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh @@ -12,6 +12,7 @@ BOLD='\033[1m' NC='\033[0m' # No Color PACMAN_BIN="/usr/bin/pacman" +MAKEPKG_CAPPED_BIN="/usr/local/bin/makepkg_capped" declare -a BLOCKED_KEYWORDS_LIST=() declare -a WHITELISTED_NAMES_LIST=() @@ -241,6 +242,22 @@ function show_help() { echo "" echo "Additional commands:" echo " --help-wrapper Show this help message" + echo " --makepkg-capped Run makepkg in a constrained systemd user scope" + echo " (forward remaining args to makepkg)" +} + +run_makepkg_capped() { + if [[ ! -x $MAKEPKG_CAPPED_BIN ]]; then + echo -e "${RED}makepkg capped wrapper not found at ${MAKEPKG_CAPPED_BIN}${NC}" >&2 + echo -e "${YELLOW}Run install_pacman_wrapper.sh to install it.${NC}" >&2 + return 1 + fi + + if [[ $EUID -eq 0 && -n ${SUDO_USER:-} ]]; then + exec sudo -u "$SUDO_USER" "$MAKEPKG_CAPPED_BIN" "$@" + fi + + exec "$MAKEPKG_CAPPED_BIN" "$@" } # Function to display a message before executing @@ -707,6 +724,11 @@ if [[ $1 == "--help-wrapper" ]]; then exit 0 fi +if [[ ${1:-} == "--makepkg-capped" ]]; then + shift + run_makepkg_capped "$@" +fi + # CRITICAL: Verify policy file integrity before any operations if ! verify_policy_integrity; then exit 1 diff --git a/linux_configuration/scripts/lib/common.sh b/linux_configuration/scripts/lib/common.sh index 794e06e..6099ec6 100755 --- a/linux_configuration/scripts/lib/common.sh +++ b/linux_configuration/scripts/lib/common.sh @@ -187,11 +187,11 @@ is_focus_app_running() { local regex wid printf -v regex '%s|' "${FOCUS_APPS_WINDOWS[@]}" regex="${regex%|}" # strip trailing | - wid=$(xdotool search --name "$regex" 2>/dev/null | head -1 || true) - if [[ -n $wid ]]; then - xdotool getwindowname "$wid" 2>/dev/null || echo "focus app" + while IFS= read -r wid; do + [[ -n $wid ]] || continue + echo "focus app" return 0 - fi + done < <(xdotool search --name "$regex" 2>/dev/null) fi # Check specific processes via /proc (no fork) diff --git a/linux_configuration/scripts/system-maintenance/bin/install_usage_monitoring.sh b/linux_configuration/scripts/system-maintenance/bin/install_usage_monitoring.sh index ed14dc7..378309a 100755 --- a/linux_configuration/scripts/system-maintenance/bin/install_usage_monitoring.sh +++ b/linux_configuration/scripts/system-maintenance/bin/install_usage_monitoring.sh @@ -32,7 +32,11 @@ for id in ${ID:-} ${ID_LIKE:-}; do FAMILY="arch" break ;; - debian | ubuntu | linuxmint | pop | elementary) + debian | ubuntu | linuxmint | pop) + FAMILY="debian" + break + ;; + elementary) FAMILY="debian" break ;; @@ -152,20 +156,24 @@ if ! command -v nvidia-smi >/dev/null 2>&1; then exit 1 fi +current_day() { + printf '%(%Y%m%d)T' -1 +} + while true; do - day="$(date +%Y%m%d)" + day="$(current_day)" out_file="$LOG_DIR/pmon-${day}.log" nvidia-smi pmon -d 10 -o DT >> "$out_file" 2>> "$ERR_LOG" & pmon_pid=$! while kill -0 "$pmon_pid" >/dev/null 2>&1; do - if [[ "$(date +%Y%m%d)" != "$day" ]]; then + if [[ "$(current_day)" != "$day" ]]; then kill "$pmon_pid" >/dev/null 2>&1 || true wait "$pmon_pid" || true break fi - read -r -t 20 _ || true + sleep 60 done done diff --git a/linux_configuration/scripts/system-maintenance/bin/usage_report.py b/linux_configuration/scripts/system-maintenance/bin/usage_report.py index ca19b50..314a68f 100755 --- a/linux_configuration/scripts/system-maintenance/bin/usage_report.py +++ b/linux_configuration/scripts/system-maintenance/bin/usage_report.py @@ -528,6 +528,39 @@ def _pmon_fields(line: str) -> list[str] | None: return s.split() +def _normalize_pmon_command(command_fields: list[str]) -> str: + """Normalize pmon command fields into a stable process-ish name. + + `nvidia-smi pmon -o DT` emits fixed numeric columns followed by a command + field that can include whitespace. We prefer the *first* non-option token + (usually executable) and normalize it to a basename. + """ + tokens = [token.strip().strip("\"'") for token in command_fields if token.strip()] + if not tokens: + return "unknown" + + selected = tokens[0] + if selected.startswith("-"): + for candidate in tokens[1:]: + if not candidate.startswith("-"): + selected = candidate + break + + name = Path(selected).name.strip(";,:") + if not name: + return "unknown" + return name + + +def _pid_comm_name(pid: int) -> str | None: + """Return `/proc//comm` basename when available.""" + try: + comm = Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip() + except OSError: + return None + return Path(comm).name if comm else None + + def aggregate_pmon( log: Path, progress: _Progress, @@ -563,7 +596,10 @@ def _ingest_pmon_row(parts: list[str], agg: dict[str, GpuAgg]) -> int: return 0 sm_raw = parts[5] mem_raw = parts[6] - name = parts[-1] + command_fields = parts[11:] + name = _normalize_pmon_command(command_fields) + if name == "unknown": + name = _pid_comm_name(pid) or "unknown" sm = float(sm_raw) if sm_raw != "-" else 0.0 mem = float(mem_raw) if mem_raw != "-" else 0.0 entry = agg.setdefault(name, GpuAgg(name=name)) diff --git a/linux_configuration/tests/__init__.py b/linux_configuration/tests/__init__.py new file mode 100644 index 0000000..160173e --- /dev/null +++ b/linux_configuration/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for linux_configuration scripts and tooling.""" diff --git a/linux_configuration/tests/test_i3blocks_efficiency.sh b/linux_configuration/tests/test_i3blocks_efficiency.sh index 95815b0..dedb22f 100755 --- a/linux_configuration/tests/test_i3blocks_efficiency.sh +++ b/linux_configuration/tests/test_i3blocks_efficiency.sh @@ -8,6 +8,9 @@ REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) I3BLOCKS_DIR="$REPO_DIR/i3-configuration/i3blocks" CONFIG_FILE="$I3BLOCKS_DIR/config" +printf 'Running persist_common helper regression checks...\n' +bash "$SCRIPT_DIR/test_i3blocks_persist_common.sh" + TMP_DIR=$(mktemp -d) BIN_DIR="$TMP_DIR/bin" mkdir -p "$BIN_DIR" @@ -164,6 +167,58 @@ grep -q '^command=~/.config/i3blocks/ethernet.sh$' "$CONFIG_FILE" \ || fail 'ethernet block should call ethernet.sh' grep -q '^command=~/.config/i3blocks/disk.sh$' "$CONFIG_FILE" \ || fail 'disk block should call disk.sh' +grep -q '^interval=10$' "$CONFIG_FILE" \ + || fail 'cpu block should poll at 10s interval' +grep -A2 '^\[motherboard_temperature\]$' "$CONFIG_FILE" | grep -q '^interval=30$' \ + || fail 'motherboard block should poll at 30s interval' +grep -A2 '^\[memory\]$' "$CONFIG_FILE" | grep -q '^interval=30$' \ + || fail 'memory block should poll at 30s interval' +grep -A2 '^\[bluetooth\]$' "$CONFIG_FILE" | grep -q '^interval=persist$' \ + || fail 'bluetooth block should use persist mode' +grep -A2 '^\[battery\]$' "$CONFIG_FILE" | grep -q '^interval=60$' \ + || fail 'battery block should poll at 60s interval' +grep -A2 '^\[ethernet\]$' "$CONFIG_FILE" | grep -q '^interval=persist$' \ + || fail 'ethernet block should use persist mode' +grep -A2 '^\[wifi\]$' "$CONFIG_FILE" | grep -q '^interval=persist$' \ + || fail 'wifi block should use persist mode' +grep -A2 '^\[activitywatch\]$' "$CONFIG_FILE" | grep -q '^interval=persist$' \ + || fail 'activitywatch block should use persist mode' +grep -A2 '^\[warp\]$' "$CONFIG_FILE" | grep -q '^interval=persist$' \ + || fail 'warp block should use persist mode' + +printf 'Checking focus detection path avoids extra xdotool lookups...\n' +! grep -Fq "xdotool getwindowname \"\$wid\"" "$REPO_DIR/scripts/lib/common.sh" \ + || fail 'focus detection should not call xdotool getwindowname in hot path' + +printf 'Checking ActivityWatch persist strategy avoids /proc event storm...\n' +! grep -Fq 'inotifywait -m -q -e create -e delete /proc' "$I3BLOCKS_DIR/activitywatch_status.sh" \ + || fail 'activitywatch persist mode should avoid noisy /proc inotify stream' + +printf 'Checking GPU/WARP dedupe guards exist...\n' +grep -Fq 'emit_if_changed()' "$I3BLOCKS_DIR/gpu_monitor.sh" \ + || fail 'gpu monitor should dedupe repeated identical samples' +grep -Fq 'emit_if_changed()' "$I3BLOCKS_DIR/warp_status.sh" \ + || fail 'warp status should dedupe repeated identical states' +grep -Fq "source \"\$SCRIPT_DIR/persist_common.sh\"" "$I3BLOCKS_DIR/bluetooth.sh" \ + || fail 'bluetooth script should use shared persist helper' +grep -Fq "source \"\$SCRIPT_DIR/persist_common.sh\"" "$I3BLOCKS_DIR/wifi_monitor.sh" \ + || fail 'wifi script should use shared persist helper' +grep -Fq "source \"\$SCRIPT_DIR/persist_common.sh\"" "$I3BLOCKS_DIR/ethernet.sh" \ + || fail 'ethernet script should use shared persist helper' +grep -Fq "source \"\$SCRIPT_DIR/persist_common.sh\"" "$I3BLOCKS_DIR/activitywatch_status.sh" \ + || fail 'activitywatch script should use shared persist helper' +grep -Fq "source \"\$SCRIPT_DIR/persist_common.sh\"" "$I3BLOCKS_DIR/gpu_monitor.sh" \ + || fail 'gpu script should use shared persist helper' +grep -Fq "source \"\$SCRIPT_DIR/persist_common.sh\"" "$I3BLOCKS_DIR/warp_status.sh" \ + || fail 'warp script should use shared persist helper' +grep -Fq 'i3blocks_update_if_changed_key "bluetooth_state"' "$I3BLOCKS_DIR/bluetooth.sh" \ + || fail 'bluetooth script should dedupe unchanged state' +grep -Fq 'i3blocks_update_if_changed_key "wifi_output"' "$I3BLOCKS_DIR/wifi_monitor.sh" \ + || fail 'wifi script should dedupe unchanged output' +grep -Fq 'i3blocks_update_if_changed_key "ethernet_output"' "$I3BLOCKS_DIR/ethernet.sh" \ + || fail 'ethernet script should dedupe unchanged output' +grep -Fq 'i3blocks_update_if_changed_key "activitywatch_state"' "$I3BLOCKS_DIR/activitywatch_status.sh" \ + || fail 'activitywatch script should dedupe unchanged state' printf 'Checking bluetooth block behavior and fork count...\n' bluetooth_output=$(PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/bluetooth.sh") diff --git a/linux_configuration/tests/test_i3blocks_persist_common.sh b/linux_configuration/tests/test_i3blocks_persist_common.sh new file mode 100755 index 0000000..382a35a --- /dev/null +++ b/linux_configuration/tests/test_i3blocks_persist_common.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Regression tests for i3blocks persist_common helper functions. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +HELPER="$REPO_DIR/i3-configuration/i3blocks/persist_common.sh" +TMP_DIR=$(mktemp -d) + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +# shellcheck source=linux_configuration/i3-configuration/i3blocks/persist_common.sh +source "$HELPER" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected=$1 + local actual=$2 + local context=$3 + if [[ "$expected" != "$actual" ]]; then + fail "$context (expected '$expected', actual '$actual')" + fi +} + +count_execs() { + local script_path=$1 + local log_file=$2 + strace -f -o "$log_file" -e trace=execve bash "$script_path" >/dev/null 2>&1 + grep -c 'execve(' "$log_file" +} + +printf 'Checking interval gating allows first emit per key...\n' +I3BLOCKS_LAST_TS=() +I3BLOCKS_TEST_NOW_TS=100 +i3blocks_should_emit_by_interval_key "wifi" 5 || fail 'first interval check should allow emit' +assert_eq '100' "${I3BLOCKS_LAST_TS[wifi]}" 'first emit should store current timestamp' + +printf 'Checking interval gating blocks too-soon second emit...\n' +I3BLOCKS_TEST_NOW_TS=102 +if i3blocks_should_emit_by_interval_key "wifi" 5; then + fail 'second interval check should block when interval has not elapsed' +fi +assert_eq '100' "${I3BLOCKS_LAST_TS[wifi]}" 'blocked emit must not overwrite timestamp' + +printf 'Checking repeated blocked emits never mutate timestamp...\n' +for _ in {1..200}; do + if i3blocks_should_emit_by_interval_key "wifi" 5; then + fail 'repeated blocked interval checks must remain blocked' + fi +done +assert_eq '100' "${I3BLOCKS_LAST_TS[wifi]}" 'repeated blocked checks must preserve original timestamp' + +printf 'Checking interval gating allows later emit...\n' +I3BLOCKS_TEST_NOW_TS=106 +i3blocks_should_emit_by_interval_key "wifi" 5 || fail 'emit should pass after interval elapsed' +assert_eq '106' "${I3BLOCKS_LAST_TS[wifi]}" 'allowed emit should update timestamp' + +printf 'Checking interval gating is key-isolated...\n' +I3BLOCKS_TEST_NOW_TS=103 +i3blocks_should_emit_by_interval_key "ethernet" 5 || fail 'new key should allow first emit independently' +assert_eq '103' "${I3BLOCKS_LAST_TS[ethernet]}" 'independent key should store its own timestamp' + +unset I3BLOCKS_TEST_NOW_TS + +printf 'Checking changed-state helper allows first state...\n' +I3BLOCKS_LAST_STATE=() +i3blocks_update_if_changed_key "wifi" "connected" || fail 'first state should be treated as changed' +assert_eq 'connected' "${I3BLOCKS_LAST_STATE[wifi]}" 'first changed state should be stored' + +printf 'Checking changed-state helper blocks identical state...\n' +if i3blocks_update_if_changed_key "wifi" "connected"; then + fail 'identical state should be treated as unchanged' +fi + +printf 'Checking changed-state helper allows new state...\n' +i3blocks_update_if_changed_key "wifi" "disconnected" || fail 'different state should be treated as changed' +assert_eq 'disconnected' "${I3BLOCKS_LAST_STATE[wifi]}" 'new state should replace old state' + +printf 'Checking empty string first state is treated as changed...\n' +I3BLOCKS_LAST_STATE=() +i3blocks_update_if_changed_key "warp" "" || fail 'first empty state should be treated as changed' +if i3blocks_update_if_changed_key "warp" ""; then + fail 'second empty state should be treated as unchanged' +fi + +printf 'Checking changed-state helper is key-isolated...\n' +i3blocks_update_if_changed_key "bluetooth" "on" || fail 'different key should track independently' +assert_eq 'on' "${I3BLOCKS_LAST_STATE[bluetooth]}" 'second key should store independent state' +assert_eq '' "${I3BLOCKS_LAST_STATE[warp]}" 'first key state should remain unchanged' + +printf 'Checking interleaved multi-key updates remain isolated...\n' +I3BLOCKS_LAST_TS=() +I3BLOCKS_LAST_STATE=() +export I3BLOCKS_TEST_NOW_TS +for i in {1..50}; do + I3BLOCKS_TEST_NOW_TS=$((1000 + i)) + i3blocks_should_emit_by_interval_key "wifi" 0 || fail 'wifi interleaved emit should pass' + i3blocks_update_if_changed_key "wifi_state" "wifi-$((i % 3))" || true + + I3BLOCKS_TEST_NOW_TS=$((2000 + i)) + i3blocks_should_emit_by_interval_key "wifi_monitor" 0 || fail 'wifi_monitor interleaved emit should pass' + i3blocks_update_if_changed_key "wifi_monitor_state" "monitor-$((i % 4))" || true + + I3BLOCKS_TEST_NOW_TS=$((3000 + i)) + i3blocks_should_emit_by_interval_key "ethernet" 0 || fail 'ethernet interleaved emit should pass' + i3blocks_update_if_changed_key "ethernet_state" "eth-$((i % 2))" || true +done +assert_eq '1050' "${I3BLOCKS_LAST_TS[wifi]}" 'wifi key should retain its own timestamp series' +assert_eq '2050' "${I3BLOCKS_LAST_TS[wifi_monitor]}" 'wifi_monitor key should retain its own timestamp series' +assert_eq '3050' "${I3BLOCKS_LAST_TS[ethernet]}" 'ethernet key should retain its own timestamp series' +assert_eq 'wifi-2' "${I3BLOCKS_LAST_STATE[wifi_state]}" 'wifi_state should keep independent final state' +assert_eq 'monitor-2' "${I3BLOCKS_LAST_STATE[wifi_monitor_state]}" 'wifi_monitor_state should keep independent final state' +assert_eq 'eth-0' "${I3BLOCKS_LAST_STATE[ethernet_state]}" 'ethernet_state should keep independent final state' +unset I3BLOCKS_TEST_NOW_TS + +printf 'Checking helper hot path stays fork-free under load...\n' +fork_probe="$TMP_DIR/persist_common_fork_probe.sh" +cat >"$fork_probe" <&2 + exit 1 +} + +printf 'Checking makepkg_capped exists...\n' +[[ -f "$WRAPPER" ]] || fail 'makepkg_capped.sh is missing' + +printf 'Checking makepkg_capped syntax...\n' +bash -n "$WRAPPER" + +printf 'Checking systemd scope limits are present...\n' +grep -Fq 'CPUQuota=' "$WRAPPER" || fail 'CPUQuota setting missing' +grep -Fq 'MemoryMax=' "$WRAPPER" || fail 'MemoryMax setting missing' +grep -Fq 'MemorySwapMax=0' "$WRAPPER" || fail 'MemorySwapMax=0 setting missing' +grep -Fq 'TasksMax=' "$WRAPPER" || fail 'TasksMax setting missing' + +printf 'Checking graceful fallback path exists...\n' +grep -Fq 'run_makepkg_fallback' "$WRAPPER" || fail 'fallback function missing' +grep -Fq 'systemd-run --user --scope --quiet true' "$WRAPPER" || fail 'user-scope probe missing' + +printf 'makepkg_capped regression checks passed.\n' diff --git a/linux_configuration/tests/test_pacman_wrapper_security.sh b/linux_configuration/tests/test_pacman_wrapper_security.sh index e1ecceb..045b0ae 100755 --- a/linux_configuration/tests/test_pacman_wrapper_security.sh +++ b/linux_configuration/tests/test_pacman_wrapper_security.sh @@ -46,21 +46,21 @@ else exit 1 fi -# Test 5: Verify hardcoded VirtualBox check exists -echo "[TEST 5] Verifying hardcoded VirtualBox check exists..." -if grep -q "is_virtualbox_package()" "$WRAPPER_DIR/pacman_wrapper.sh"; then - echo "✓ Hardcoded VirtualBox check function found" +# Test 5: Verify hardcoded VirtualBox cleanup function exists +echo "[TEST 5] Verifying hardcoded VirtualBox cleanup function exists..." +if grep -q "auto_remove_virtualbox_vms()" "$WRAPPER_DIR/pacman_wrapper.sh"; then + echo "✓ Hardcoded VirtualBox cleanup function found" else - echo "✗ Hardcoded VirtualBox check function not found" + echo "✗ Hardcoded VirtualBox cleanup function not found" exit 1 fi -# Test 6: Verify VirtualBox challenge function exists -echo "[TEST 6] Verifying VirtualBox challenge function exists..." -if grep -q "prompt_for_virtualbox_challenge()" "$WRAPPER_DIR/pacman_wrapper.sh"; then - echo "✓ VirtualBox challenge function found" +# Test 6: Verify VirtualBox cleanup uses VBoxManage directly +echo "[TEST 6] Verifying VirtualBox cleanup uses VBoxManage directly..." +if grep -q "VBoxManage" "$WRAPPER_DIR/pacman_wrapper.sh"; then + echo "✓ VirtualBox cleanup logic found" else - echo "✗ VirtualBox challenge function not found" + echo "✗ VirtualBox cleanup logic not found" exit 1 fi @@ -91,12 +91,12 @@ else exit 1 fi -# Test 10: Verify VirtualBox enforcement is integrated -echo "[TEST 10] Verifying VirtualBox enforcement is integrated into wrapper..." -if grep -q "enforce_vbox_hosts_if_needed" "$WRAPPER_DIR/pacman_wrapper.sh"; then - echo "✓ VirtualBox enforcement integration found" +# Test 10: Verify VirtualBox cleanup enforcement is integrated +echo "[TEST 10] Verifying VirtualBox cleanup is integrated into wrapper..." +if grep -q "auto_remove_virtualbox_vms" "$WRAPPER_DIR/pacman_wrapper.sh"; then + echo "✓ VirtualBox cleanup integration found" else - echo "✗ VirtualBox enforcement integration not found" + echo "✗ VirtualBox cleanup integration not found" exit 1 fi @@ -119,6 +119,69 @@ else exit 1 fi +# Test 13: Verify makepkg capped wrapper script syntax +echo "[TEST 13] Checking makepkg capped wrapper syntax..." +if bash -n "$WRAPPER_DIR/makepkg_capped.sh"; then + echo "✓ makepkg capped wrapper syntax is valid" +else + echo "✗ makepkg capped wrapper has syntax errors" + exit 1 +fi + +# Test 14: Verify pacman wrapper exposes makepkg capped command +echo "[TEST 14] Verifying pacman wrapper supports --makepkg-capped..." +if grep -q -- "--makepkg-capped" "$WRAPPER_DIR/pacman_wrapper.sh"; then + echo "✓ pacman wrapper makepkg capped command found" +else + echo "✗ pacman wrapper makepkg capped command missing" + exit 1 +fi + +# Test 15: Verify installer deploys makepkg capped wrapper +echo "[TEST 15] Verifying installer deploys makepkg capped wrapper..." +if grep -q "MAKEPKG_CAPPED" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then + echo "✓ Installer includes makepkg capped deployment" +else + echo "✗ Installer does not include makepkg capped deployment" + exit 1 +fi + +# Test 16: Verify mkpkg helper script syntax +echo "[TEST 16] Checking mkpkg helper script syntax..." +if bash -n "$WRAPPER_DIR/mkpkg.sh"; then + echo "✓ mkpkg helper script syntax is valid" +else + echo "✗ mkpkg helper script has syntax errors" + exit 1 +fi + +# Test 17: Verify installer deploys mkpkg helper +echo "[TEST 17] Verifying installer deploys mkpkg helper..." +if grep -q "MKPKG" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then + echo "✓ Installer includes mkpkg helper deployment" +else + echo "✗ Installer does not include mkpkg helper deployment" + exit 1 +fi + +# Test 18: Verify installer runs in strict mode +echo "[TEST 18] Verifying installer uses strict shell mode..." +if grep -q "set -euo pipefail" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then + echo "✓ Installer strict mode enabled" +else + echo "✗ Installer strict mode not enabled" + exit 1 +fi + +# Test 19: Verify installer handles immutable files during updates +echo "[TEST 19] Verifying installer unlocks immutable files before copy/write..." +if grep -q "unlock_immutable_file_if_needed" "$WRAPPER_DIR/install_pacman_wrapper.sh"; then + echo "✓ Installer immutable-file handling found" +else + echo "✗ Installer immutable-file handling missing" + exit 1 +fi + echo "" echo "=== All Tests Passed! ===" echo "" @@ -129,3 +192,6 @@ echo " ✓ Policy files are made immutable with chattr +i" echo " ✓ VirtualBox has hardcoded restrictions (cannot bypass via file editing)" echo " ✓ VirtualBox VMs are automatically configured to use host's /etc/hosts" echo " ✓ Difficult word challenge for VirtualBox installation (7-letter words, 150 words, 120s)" +echo " ✓ makepkg capped runner is integrated via wrapper and installer" +echo " ✓ mkpkg convenience helper is deployed by installer" +echo " ✓ installer fails fast and handles immutable policy files safely" diff --git a/linux_configuration/tests/test_usage_monitoring_installer_efficiency.sh b/linux_configuration/tests/test_usage_monitoring_installer_efficiency.sh new file mode 100755 index 0000000..d93ffd2 --- /dev/null +++ b/linux_configuration/tests/test_usage_monitoring_installer_efficiency.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Regression tests for nvidia-pmon logger installer template efficiency. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +INSTALLER="$REPO_DIR/scripts/system-maintenance/bin/install_usage_monitoring.sh" + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +logger_template=$( + awk ' + /cat > "\$HOME\/.local\/bin\/nvidia-pmon-logger\.sh" << '\''SCRIPT'\''/ {capture=1; next} + capture && /^SCRIPT$/ {capture=0; exit} + capture {print} + ' "$INSTALLER" +) + +[[ -n $logger_template ]] || fail 'could not extract nvidia-pmon-logger template from installer' + +printf 'Checking pmon logger template avoids read -t busy-loop pattern...\n' +! grep -q 'read -r -t' <<< "$logger_template" \ + || fail 'logger template must not use read -t as sleep surrogate' + +printf 'Checking pmon logger template uses sleep-based waiting...\n' +grep -q 'sleep 60' <<< "$logger_template" \ + || fail 'logger template must sleep between day rollover checks' + +printf 'Checking pmon logger template uses fork-free date builtin...\n' +grep -q "printf '%(%Y%m%d)T' -1" <<< "$logger_template" \ + || fail 'logger template must use bash printf time builtin for current day' + +printf 'Checking pmon logger template avoids external date command...\n' +! grep -q 'date +%Y%m%d' <<< "$logger_template" \ + || fail 'logger template must not call external date command in hot path' + +printf 'Usage monitoring installer efficiency tests passed.\n' diff --git a/linux_configuration/tests/test_usage_report_pmon_names.py b/linux_configuration/tests/test_usage_report_pmon_names.py new file mode 100644 index 0000000..7905a09 --- /dev/null +++ b/linux_configuration/tests/test_usage_report_pmon_names.py @@ -0,0 +1,92 @@ +"""Regression tests for pmon process-name normalization in usage_report.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pytest + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "system-maintenance" + / "bin" + / "usage_report.py" +) +SPEC = importlib.util.spec_from_file_location("usage_report", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + msg = "could not load usage_report module" + raise RuntimeError(msg) +usage_report = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = usage_report +SPEC.loader.exec_module(usage_report) + + +def test_normalize_pmon_command_prefers_first_executable_token() -> None: + """The parser should keep executable-like token, not trailing args.""" + tokens = ["code-insiders", "--type=", "gpu-process", "Not"] + + assert usage_report._normalize_pmon_command(tokens) == "code-insiders" + + +def test_normalize_pmon_command_skips_leading_option_tokens() -> None: + """If the first token is an option, use the next non-option token.""" + tokens = ["--type=", "code-insiders", "--flag"] + + assert usage_report._normalize_pmon_command(tokens) == "code-insiders" + + +def test_ingest_pmon_row_uses_command_field_start_not_last_token() -> None: + """Rows with command args should aggregate under process name, not args.""" + row = [ + "20260507", + "12:00:00", + "0", + "123", + "C", + "10", + "5", + "0", + "0", + "0", + "0", + "code-insiders", + "--type=", + "gpu-process", + ] + agg: dict[str, object] = {} + + consumed = usage_report._ingest_pmon_row(row, agg) + + assert consumed == 1 + assert "code-insiders" in agg + + +def test_ingest_pmon_row_falls_back_to_proc_comm_on_unknown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When command field is empty, parser can recover with /proc//comm.""" + row = [ + "20260507", + "12:00:00", + "0", + "999", + "C", + "30", + "10", + "0", + "0", + "0", + "0", + ] + agg: dict[str, object] = {} + + monkeypatch.setattr(usage_report, "_pid_comm_name", lambda _pid: "python") + consumed = usage_report._ingest_pmon_row(row, agg) + + assert consumed == 1 + assert "python" in agg diff --git a/python_pkg/steam_backlog_enforcer/enforcer.py b/python_pkg/steam_backlog_enforcer/enforcer.py index 69390f9..2a60983 100644 --- a/python_pkg/steam_backlog_enforcer/enforcer.py +++ b/python_pkg/steam_backlog_enforcer/enforcer.py @@ -9,6 +9,8 @@ import shutil import signal import subprocess +from python_pkg.steam_backlog_enforcer.game_install import PROTECTED_APP_IDS + logger = logging.getLogger(__name__) @@ -58,6 +60,8 @@ def enforce_allowed_game( # Skip Steam client itself (app_id 0 or very low IDs). if app_id == 0: continue + if app_id in PROTECTED_APP_IDS: + continue violations.append((pid, app_id)) if kill_unauthorized: diff --git a/python_pkg/steam_backlog_enforcer/game_install.py b/python_pkg/steam_backlog_enforcer/game_install.py index be0c11f..e8330ee 100644 --- a/python_pkg/steam_backlog_enforcer/game_install.py +++ b/python_pkg/steam_backlog_enforcer/game_install.py @@ -85,6 +85,7 @@ PROTECTED_APP_IDS = { 2252570, 220200, 3527290, # Peak + 1331550, } STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser() diff --git a/python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service b/python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service index babf1d7..258c9d7 100644 --- a/python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service +++ b/python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service @@ -10,7 +10,7 @@ ExecStart=/usr/bin/python3 -m python_pkg.steam_backlog_enforcer.main enforce Restart=always RestartSec=5 Environment=PYTHONUNBUFFERED=1 -Environment=PYTHONPATH=/home/kuhy/.local/lib/python3.14/site-packages +Environment=PYTHONPATH=/home/kuhy/testsAndMisc:/home/kuhy/.local/lib/python3.14/site-packages Environment=HOME=/home/kuhy # Hardening: enforcer must not be easily killed. OOMScoreAdjust=-900 diff --git a/python_pkg/steam_backlog_enforcer/tests/test_enforcer.py b/python_pkg/steam_backlog_enforcer/tests/test_enforcer.py index fb77c96..b935aed 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_enforcer.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_enforcer.py @@ -133,6 +133,25 @@ class TestEnforceAllowedGame: result = enforce_allowed_game(None, kill_unauthorized=True) assert result == [] + def test_skips_protected_app_id(self) -> None: + """Protected IDs must never be killed even if not the assigned game.""" + with ( + patch( + "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", + return_value={100: 1331550, 200: 440}, + ), + patch( + "python_pkg.steam_backlog_enforcer.enforcer.PROTECTED_APP_IDS", + {1331550}, + ), + patch( + "python_pkg.steam_backlog_enforcer.enforcer.kill_process" + ) as mock_kill, + ): + result = enforce_allowed_game(440, kill_unauthorized=True) + assert result == [] + mock_kill.assert_not_called() + class TestKillProcess: """Tests for kill_process."""