diff --git a/docs/superpowers/contracts/pacman-wrapper-split-2026-08-21.json b/docs/superpowers/contracts/pacman-wrapper-split-2026-08-21.json new file mode 100644 index 00000000..f117ef1f --- /dev/null +++ b/docs/superpowers/contracts/pacman-wrapper-split-2026-08-21.json @@ -0,0 +1,19 @@ +{ + "title": "Split pacman_wrapper.sh under the 250-line cap", + "objective": "Bring pacman_wrapper.sh (929 lines) under the cap by moving its 31 functions into five flat-deployed libraries, without any failure mode that could leave this machine without a working package manager — /usr/bin/pacman is a symlink to the deployed copy.", + "acceptance_criteria": [ + "The entry script and all five pw_*.sh libs are at most 250 lines.", + "Real read-only pacman queries through the split wrapper (-Qi bash, -Q) are byte-identical to the pre-split baseline.", + "A missing library FAILS OPEN: warning on stderr, exec of real pacman, correct output and exit status.", + "The five libs are deployed by install_files.sh and covered by the integrity manifest; tampering with one is detected and denied.", + "verify_shell_split.sh reports all functions moved verbatim.", + "The deployed /usr/local/bin/pacman_wrapper is byte-identical to the pre-work backup, and live pacman still works.", + "shellcheck passes without -x, shfmt is a no-op, no suppression added." + ], + "out_of_scope": [ + "Running a live `pacman -S` transaction: the Bash tool deadlocks on db.lck and a failure costs the package manager.", + "Re-deploying to /usr/local/bin — a separate, user-run step.", + "The pre-existing SC2329 suppression on load_policy_lists, which predates this split." + ], + "verifier": "Real -Qi/-Q diffs against the captured baseline; a scratch-dir fail-open simulation with a lib removed; an integrity round-trip proving tamper detection covers the new libs; verify_shell_split.sh; sha256 of the deployed copy vs the pre-work backup; shellcheck + shfmt + check_file_length --all." +} diff --git a/docs/superpowers/evidence/pacman-wrapper-split-2026-08-21.json b/docs/superpowers/evidence/pacman-wrapper-split-2026-08-21.json new file mode 100644 index 00000000..f1e67013 --- /dev/null +++ b/docs/superpowers/evidence/pacman-wrapper-split-2026-08-21.json @@ -0,0 +1,65 @@ +{ + "intent": "Bring pacman_wrapper.sh (929 lines) under the 250-line cap. This file IS /usr/bin/pacman on this machine — /usr/bin/pacman is a symlink to the deployed copy — so the overriding constraint is that no failure mode may leave the box without a working package manager.", + "scope": [ + "pacman_wrapper.sh: 929 -> 217 lines", + "New libs, FLAT beside the wrapper: pw_policy.sh (188), pw_challenge.sh (177), pw_cleanup.sh (153), pw_guard.sh (145), pw_ui.sh (90)", + "install_pacman_wrapper.sh + lib/install_files.sh: deploy the five libs", + "lib/integrity.sh: checksum them in the policy manifest", + "Non-goal: redeploying to /usr/local/bin, and any live `pacman -S` transaction" + ], + "changes": [ + "All 31 functions moved into five libs by responsibility. This needed a fix to extract_shell_functions.py first (committed separately): 17 of the 31 use bash's `function name() {` spelling, which the extractor did not recognise and would have silently left behind.", + "Libs are deployed FLAT beside the wrapper, matching pacman_lock_lib.sh and heavy_job_lock.sh, which already live and are sourced that way in /usr/local/bin. No lib/ subdirectory is created there.", + "The source block sits at the TOP of the file, before the fast path, because the fast path calls needs_unlock() to decide whether to exec real pacman and that function now lives in pw_guard.sh.", + "FAIL OPEN, unlike every other split in this campaign. A missing lib prints a warning to stderr and execs real pacman unwrapped. Failing closed here would mean a missing lib leaves no way to install anything — including the missing lib. This mirrors the choice the pre-existing pacman_lock_lib.sh block already makes.", + "The five libs are registered in install_files.sh as REQUIRED copies and added to the integrity manifest. They are sourced before verify_policy_integrity runs (the fast path forces that ordering), so the manifest entry is what makes tampering detectable — the check still refuses the transaction afterwards.", + "BOLD and MAKEPKG_CAPPED_BIN are exported: they are read by pw_ui.sh and pw_cleanup.sh, which shellcheck cannot see across the source boundary without -x." + ], + "verification": [ + { + "command": "PACMAN_BIN=/usr/bin/pacman.orig bash pacman_wrapper.sh -Qi bash / -Q", + "result": "pass", + "evidence": "REAL pacman queries through the split wrapper, byte-identical to the pre-split baseline: -Qi bash matches exactly, and -Q lists the same 2824 packages. This is execution, not textual proof — better than the --help-only baseline the prompt expected for this file." + }, + { + "command": "fail-open simulation: flat layout in a scratch dir with pw_guard.sh removed", + "result": "pass", + "evidence": "THE critical safety property. With needs_unlock's lib missing, the wrapper prints 'pacman wrapper libraries missing; running pacman unwrapped.' to stderr, execs real pacman, returns rc=0 and real -Qi output. A missing lib cannot brick package management." + }, + { + "command": "integrity round-trip in a fake root", + "result": "pass", + "evidence": "Built the manifest exactly as the patched installer does: 8 entries (2 policy lists + lock lib + 5 new libs). Untampered libs verify clean. Appending one line to pw_challenge.sh is DETECTED and the wrapper denies operation. The new libs are genuinely covered, not a hole in the check." + }, + { + "command": "bash meta/scripts/verify_shell_split.sh HEAD <5 libs>", + "result": "pass", + "evidence": "'IDENTICAL: all 14 top-level functions moved verbatim' — no body changed." + }, + { + "command": "python3 line-multiset check", + "result": "pass", + "evidence": "Two apparent losses, both explained: BOLD and MAKEPKG_CAPPED_BIN now carry an `export` prefix. Both confirmed present in the entry script; no code line was lost." + }, + { + "command": "sha256sum /usr/local/bin/pacman_wrapper vs the backup taken before any edit", + "result": "pass", + "evidence": "548fc2b53ee4cea6... on both. The deployed copy is untouched by this commit, which is why live pacman is unaffected. `pacman -Qi bash` through the real /usr/bin/pacman symlink still works, and the symlink is intact." + }, + { + "command": "shellcheck (no -x), shfmt idempotence, hyphenated-key canary, check_file_length --all", + "result": "pass", + "evidence": "shellcheck rc=0 with no new suppression; shfmt a no-op on all eight touched files; canary clean. The over-cap list is down to a single file." + } + ], + "risks": [ + "NOT VERIFIED against a live `pacman -S` transaction. The Bash tool deadlocks on db.lck for interactive pacman, and a failed experiment here costs the package manager. The evidence is instead: identical real read-only queries, a proven fail-open path, and a proven integrity round-trip. Running `sudo install_pacman_wrapper.sh` followed by a real install remains the one check only the user can perform.", + "The deployed copy still lacks the pw_*.sh libs until install_pacman_wrapper.sh is re-run. That ordering is safe in both directions: the deployed monolith is unchanged and self-contained, and if the new entry script were deployed without libs it would fail open to plain pacman rather than break.", + "The libs are sourced before verify_policy_integrity, so a tampered lib executes before the manifest is checked. Unavoidable given the fast path needs needs_unlock, and the same was already true of the wrapper file itself; the transaction is still refused." + ], + "rollback": [ + "git revert the commit; pacman_wrapper.sh returns to one 929-line file and the pw_*.sh libs disappear.", + "No live rollback needed — the deployed copy was never written.", + "If the split HAD been deployed: `sudo cp /tmp/.../restore/pacman_wrapper.deployed.bak /usr/local/bin/pacman_wrapper` restores the byte-identical monolith, and /usr/bin/pacman.orig (a real 157 KB ELF, verified running Pacman v7.1.0) is the escape hatch if even that fails." + ] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh index 0f3115af..ed6b4054 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/install_pacman_wrapper.sh @@ -31,6 +31,10 @@ NC='\033[0m' # No Color # linter accepts. `$0` is the entry script either way. WRAPPER_SOURCE="$(dirname "$0")/pacman_wrapper.sh" LOCK_LIB_SOURCE="$(dirname "$0")/pacman_lock_lib.sh" +# The wrapper's own phase libraries, deployed FLAT beside it (same shape as +# pacman_lock_lib.sh). Named explicitly rather than globbed so a stray pw_*.sh +# in a working tree cannot be deployed by accident. +PW_LIBS=(pw_policy pw_guard pw_ui pw_challenge pw_cleanup) WORDS_SOURCE="$(dirname "$0")/words.txt" BLOCKED_SOURCE="$(dirname "$0")/pacman_blocked_keywords.txt" WHITELIST_SOURCE="$(dirname "$0")/pacman_whitelist.txt" diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/install_files.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/install_files.sh index a993cb27..aa529c73 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/install_files.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/install_files.sh @@ -10,6 +10,18 @@ install_managed_files() { copy_managed_file "$WRAPPER_SOURCE" "$WRAPPER_DEST" required "wrapper script" copy_managed_file "$LOCK_LIB_SOURCE" "$LOCK_LIB_DEST" required "stale-lock library" chmod 644 "$LOCK_LIB_DEST" + + # The wrapper's phase libraries. They land flat beside the wrapper because + # pacman_wrapper.sh resolves them from its own directory, which is + # /usr/local/bin once installed. Required, not optional: without them the + # wrapper still runs but falls open to unwrapped pacman, so a partial + # install would silently disable every policy check. + local pw_lib + for pw_lib in "${PW_LIBS[@]}"; do + copy_managed_file "$(dirname "$WRAPPER_SOURCE")/${pw_lib}.sh" \ + "${INSTALL_DIR}/${pw_lib}.sh" required "wrapper library ${pw_lib}" + chmod 644 "${INSTALL_DIR}/${pw_lib}.sh" + done 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" diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/integrity.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/integrity.sh index d14124f3..ba5dea16 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/integrity.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/lib/integrity.sh @@ -46,6 +46,18 @@ write_policy_integrity_file() { echo -e "${RED}Failed to checksum lock library${NC}" >&2 exit 1 } + # Same reasoning for the wrapper's phase libraries: they are sourced by + # pacman_wrapper.sh, so an unchecksummed lib would be an unguarded way + # to run code inside the wrapper. They are sourced BEFORE + # verify_policy_integrity (the fast path needs needs_unlock), so this + # manifest entry is what makes tampering detectable rather than what + # prevents the source — the check still refuses the transaction. + for pw_lib in "${PW_LIBS[@]}"; do + sha256sum "${INSTALL_DIR}/${pw_lib}.sh" || { + echo -e "${RED}Failed to checksum wrapper library ${pw_lib}${NC}" >&2 + exit 1 + } + done # Whitelist is optional if [[ -f "$WHITELIST_DEST" ]]; then sha256sum "$WHITELIST_DEST" || { diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh index 0f43f9ea..b5e25eb2 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh @@ -8,658 +8,57 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' -BOLD='\033[1m' +# Exported: consumed by pw_ui.sh and pw_cleanup.sh, which are sourced +# separately, so shellcheck cannot see the use without -x. +export BOLD='\033[1m' NC='\033[0m' # No Color PACMAN_BIN="/usr/bin/pacman" + +# --------------------------------------------------------------------------- +# Libraries. Sourced HERE, at the very top, because the fast path below execs +# real pacman and needs_unlock() — which decides whether to take it — now lives +# in pw_guard.sh. Deployed FLAT beside this script in /usr/local/bin, the same +# shape as pacman_lock_lib.sh and heavy_job_lock.sh; in the repo they sit +# beside it too, so one resolution rule covers both. +# +# FAIL OPEN, deliberately, and unlike every other split in this campaign: this +# file IS /usr/bin/pacman. If a lib goes missing, aborting would leave the +# machine with no working package manager and no way to reinstall the lib. So +# a missing lib degrades to plain pacman with a warning on stderr, which is +# the same choice the existing pacman_lock_lib.sh block below already makes. +# +# The integrity check that guards the POLICY files still runs below, before +# any transaction is allowed through. +# --------------------------------------------------------------------------- +_WRAPPER_DIR="$(dirname "$(readlink -f "$0")")" +for _pw_lib in pw_policy pw_guard pw_ui pw_challenge pw_cleanup; do + if [[ -r "$_WRAPPER_DIR/${_pw_lib}.sh" ]]; then + # shellcheck source=/dev/null + source "$_WRAPPER_DIR/${_pw_lib}.sh" + fi +done +unset _pw_lib + +if ! declare -F needs_unlock >/dev/null || ! declare -F verify_policy_integrity >/dev/null; then + printf 'Warning: pacman wrapper libraries missing; running pacman unwrapped.\n' >&2 + exec "$PACMAN_BIN" "$@" +fi # Installed alongside the wrapper by install_pacman_wrapper.sh. Absent on a # machine that has not run it yet, in which case transactions simply are not # serialised (see the guarded source below). HEAVY_JOB_LOCK_LIB="/usr/local/bin/heavy_job_lock.sh" -MAKEPKG_CAPPED_BIN="/usr/local/bin/makepkg_capped" +# Exported: used only by pw_ui.sh (sourced separately). +export MAKEPKG_CAPPED_BIN="/usr/local/bin/makepkg_capped" -declare -a BLOCKED_KEYWORDS_LIST=() -declare -a WHITELISTED_NAMES_LIST=() -declare -a GREYLISTED_KEYWORDS_LIST=() -POLICY_LISTS_LOADED=0 -INTEGRITY_DIR="/var/lib/pacman-wrapper" -INTEGRITY_FILE="${INTEGRITY_DIR}/policy.sha256" - -# Verify integrity of policy files -verify_policy_integrity() { - if [[ ! -f $INTEGRITY_FILE ]]; then - echo -e "${RED}SECURITY WARNING: Policy integrity file missing!${NC}" >&2 - echo -e "${RED}The pacman wrapper may have been tampered with.${NC}" >&2 - echo -e "${RED}Please reinstall the wrapper using: sudo install_pacman_wrapper.sh${NC}" >&2 - return 1 - fi - - local script_dir - script_dir="$(dirname "$(readlink -f "$0")")" - local blocked_file="$script_dir/pacman_blocked_keywords.txt" - local greylist_file="$script_dir/pacman_greylist.txt" - local whitelist_file="$script_dir/pacman_whitelist.txt" - - # Verify checksums - local failed=0 - while IFS= read -r line; do - local expected_hash expected_file - expected_hash=$(echo "$line" | awk '{print $1}') - expected_file=$(echo "$line" | awk '{print $2}') - - if [[ -f $expected_file ]]; then - local actual_hash - actual_hash=$(sha256sum "$expected_file" 2>/dev/null | awk '{print $1}') - if [[ $actual_hash != "$expected_hash" ]]; then - echo -e "${RED}SECURITY WARNING: Policy file integrity check failed for $expected_file${NC}" >&2 - failed=1 - fi - fi - done <"$INTEGRITY_FILE" - - if [[ $failed -eq 1 ]]; then - echo -e "${RED}CRITICAL: Policy files have been tampered with!${NC}" >&2 - echo -e "${RED}This could be an attempt to bypass security restrictions.${NC}" >&2 - echo -e "${RED}Wrapper operation DENIED. Please reinstall using: sudo install_pacman_wrapper.sh${NC}" >&2 - return 1 - fi - - return 0 -} - -# shellcheck disable=SC2329 # invoked indirectly, see is_blocked_package_name/is_greylisted_package_name callers below -load_policy_lists() { - if [[ $POLICY_LISTS_LOADED -eq 1 ]]; then - return - fi - - local script_dir - script_dir="$(dirname "$(readlink -f "$0")")" - local blocked_file="$script_dir/pacman_blocked_keywords.txt" - local whitelist_file="$script_dir/pacman_whitelist.txt" - local greylist_file="$script_dir/pacman_greylist.txt" - - read_policy_list_file() { - local file_path="$1" - local -n target_array="$2" - local line="" - - target_array=() - while IFS= read -r line || [[ -n $line ]]; do - line="${line%$'\r'}" - if [[ $line =~ ^[[:space:]]*(#|$) ]]; then - continue - fi - target_array+=("${line,,}") - done <"$file_path" - } - - if [[ -f $blocked_file ]]; then - read_policy_list_file "$blocked_file" BLOCKED_KEYWORDS_LIST - else - BLOCKED_KEYWORDS_LIST=() - echo -e "${YELLOW}Warning:${NC} Missing blocked keywords file at $blocked_file" >&2 - fi - - if [[ -f $whitelist_file ]]; then - read_policy_list_file "$whitelist_file" WHITELISTED_NAMES_LIST - else - WHITELISTED_NAMES_LIST=() - fi - - if [[ -f $greylist_file ]]; then - read_policy_list_file "$greylist_file" GREYLISTED_KEYWORDS_LIST - else - GREYLISTED_KEYWORDS_LIST=() - fi - - POLICY_LISTS_LOADED=1 -} -# Determine if this invocation may perform a transaction (upgrade/install/remove) -needs_unlock() { - # If args include -S (install/upgrade), -U (local install), or -R (remove), we unlock - # Also include -Su/-Syu/-Syuu when -S is part of the combined flag - for arg in "$@"; do - case "$arg" in - -S*) - return 0 - ;; - -U) - return 0 - ;; - -R) - return 0 - ;; - --sync) - return 0 - ;; - --upgrade) - return 0 - ;; - --remove) - return 0 - ;; - esac - done - return 1 -} - -pacman_hooks_manage_guard_lib() { - local pre_hook="/etc/pacman.d/hooks/10-guard-lib-unlock-all.hook" - local post_hook="/etc/pacman.d/hooks/90-guard-lib-relock-all.hook" - local pre_exec="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh" - local post_exec="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh" - - if [[ ! -f $pre_hook || ! -f $post_hook ]]; then - return 1 - fi - - grep -Fq "$pre_exec" "$pre_hook" && grep -Fq "$post_exec" "$post_hook" -} - -should_use_wrapper_guard_lib_fallback() { - if ! needs_unlock "$@"; then - return 1 - fi - - if pacman_hooks_manage_guard_lib; then - return 1 - fi - - return 0 -} - -# Run guard-lib's own generic unlock-all/relock-all scripts directly if -# pacman's own hooks for them are missing (e.g. hooks disabled/misconfigured). -# These cover every registered file-guard instance (hosts, nsswitch, -# resolved, shutdown-schedule, ...), not just /etc/hosts. -pre_unlock_guard_lib() { - local pre="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh" - if [[ -x $pre ]]; then - echo -e "${CYAN}[guard-lib] Preparing guarded files for transaction...${NC}" >&2 - /bin/bash "$pre" || true - fi -} - -post_relock_guard_lib() { - local post="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh" - if [[ -x $post ]]; then - /bin/bash "$post" || true - echo -e "${CYAN}[guard-lib] Protections re-applied to guarded files.${NC}" >&2 - fi -} - -# Ensure periodic system services (timer/monitor) are set up; if not, trigger setup -ensure_periodic_maintenance() { - # Only proceed if systemd/systemctl is available - if ! command -v systemctl >/dev/null 2>&1; then - return 0 - fi - - local timer_unit="periodic-system-maintenance.timer" - local startup_unit="periodic-system-startup.service" - local monitor_unit="hosts-file-monitor.service" - local needs_setup=0 - - # Timer should be enabled and active - systemctl --quiet is-enabled "$timer_unit" || needs_setup=1 - systemctl --quiet is-active "$timer_unit" || needs_setup=1 - - # Monitor should be enabled and active - systemctl --quiet is-enabled "$monitor_unit" || needs_setup=1 - systemctl --quiet is-active "$monitor_unit" || needs_setup=1 - - # Startup service should be enabled (it’s oneshot and may not be active except at boot) - systemctl --quiet is-enabled "$startup_unit" || needs_setup=1 - - if [[ $needs_setup -eq 0 ]]; then - return 0 - fi - - echo -e "${YELLOW}Periodic maintenance services missing or inactive. Running setup...${NC}" >&2 - - # Try to locate setup_periodic_system.sh. The installed wrapper lives in - # /usr/local/bin (so $self_dir won't contain it) and, for real transactions, - # runs as root under sudo (so $HOME points at /root). Resolve the invoking - # user's home via SUDO_USER and probe the known repo locations. - local setup_script="" - local self_dir real_user real_home - self_dir="$(dirname "$(readlink -f "$0")")" - real_user="${SUDO_USER:-${USER:-$(id -un)}}" - real_home="$(getent passwd "$real_user" 2>/dev/null | cut -d: -f6)" - [[ -z $real_home ]] && real_home="$HOME" - - local -a setup_candidates=( - "$self_dir/setup_periodic_system.sh" - "$real_home/testsAndMisc/linux_configuration/scripts/periodic_background/setup_periodic_system.sh" - "$real_home/linux_configuration/scripts/periodic_background/setup_periodic_system.sh" - "$real_home/linux-configuration/scripts/periodic_background/setup_periodic_system.sh" - ) - local candidate - for candidate in "${setup_candidates[@]}"; do - if [[ -f $candidate ]]; then - setup_script="$candidate" - break - fi - done - - if [[ -n $setup_script ]]; then - if [[ $EUID -ne 0 ]]; then - sudo bash "$setup_script" - else - bash "$setup_script" - fi - echo -e "${CYAN}Tip:${NC} To disable these later:" >&2 - echo " sudo systemctl disable periodic-system-maintenance.timer" >&2 - echo " sudo systemctl disable periodic-system-startup.service" >&2 - echo " sudo systemctl disable hosts-file-monitor.service" >&2 - else - echo -e "${RED}Could not locate setup_periodic_system.sh to configure services automatically.${NC}" >&2 - fi -} - -# Function to display help -function show_help() { - echo -e "${BOLD}Pacman Wrapper Help${NC}" - echo "This wrapper adds helpful features while preserving all pacman functionality." - 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 -function display_operation() { - case "$1" in - -S) - echo -e "${BLUE}Installing packages...${NC}" >&2 - ;; - -Sy) - echo -e "${BLUE}Installing packages...${NC}" >&2 - ;; - -S\ *) - echo -e "${BLUE}Installing packages...${NC}" >&2 - ;; - -Syu | -Syyu) - echo -e "${BLUE}Updating system...${NC}" >&2 - ;; - -R) - echo -e "${YELLOW}Removing packages...${NC}" >&2 - ;; - -Rs) - echo -e "${YELLOW}Removing packages...${NC}" >&2 - ;; - -Rns) - echo -e "${YELLOW}Removing packages...${NC}" >&2 - ;; - -R\ *) - echo -e "${YELLOW}Removing packages...${NC}" >&2 - ;; - -Ss) - echo -e "${CYAN}Searching for packages...${NC}" >&2 - ;; - -Ss\ *) - echo -e "${CYAN}Searching for packages...${NC}" >&2 - ;; - -Q) - echo -e "${CYAN}Querying package database...${NC}" >&2 - ;; - -Qs) - echo -e "${CYAN}Querying package database...${NC}" >&2 - ;; - -Qi) - echo -e "${CYAN}Querying package database...${NC}" >&2 - ;; - -Ql) - echo -e "${CYAN}Querying package database...${NC}" >&2 - ;; - -Q\ *) - echo -e "${CYAN}Querying package database...${NC}" >&2 - ;; - -U) - echo -e "${BLUE}Installing local packages...${NC}" >&2 - ;; - -U\ *) - echo -e "${BLUE}Installing local packages...${NC}" >&2 - ;; - -Scc) - echo -e "${YELLOW}Cleaning package cache...${NC}" >&2 - ;; - *) - echo -e "${CYAN}Executing pacman command...${NC}" >&2 - ;; - esac -} - -# Helper: strip a pacman package-file suffix (---.pkg.tar.) -# down to the bare package name, if present. `pacman -U ` (what `yay` -# always uses to install a package it just built locally) passes a filename -# like "ungoogled-chromium-bin-150.0.7871.186-1-x86_64.pkg.tar.zst", not the -# bare name — without this, an exact-match whitelist entry for -# "ungoogled-chromium-bin" could never match a `-U` install and would always -# fall through to the (correctly still-substring) blocked-keywords check. -# Leaves package names without this suffix (the `-S` case) untouched. -# shellcheck disable=SC2329 # invoked indirectly by name (is_blocked_package_name) -function strip_pkgfile_suffix() { - local name="$1" - if [[ $name =~ ^(.+)-[^-]+-[0-9]+-(x86_64|any|i686|aarch64)\.pkg\.tar\.(zst|xz|gz|bz2|lrz|lzo|Z)$ ]]; then - printf '%s' "${BASH_REMATCH[1]}" - else - printf '%s' "$name" - fi -} - -# Helper: return 0 if the given package name is blocked by policy -# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for) -function is_blocked_package_name() { - load_policy_lists - local normalized="${1,,}" - local bare_name - bare_name="$(strip_pkgfile_suffix "$normalized")" - - for allowed in "${WHITELISTED_NAMES_LIST[@]}"; do - if [[ $normalized == "$allowed" || $bare_name == "$allowed" ]]; then - return 1 - fi - done - - for keyword in "${BLOCKED_KEYWORDS_LIST[@]}"; do - if [[ $normalized == *"$keyword"* ]]; then - return 0 - fi - done - - return 1 -} - -# Helper: return 0 if the given package name is greylisted (challenge required) -# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for) -function is_greylisted_package_name() { - load_policy_lists - local normalized="${1,,}" - - for keyword in "${GREYLISTED_KEYWORDS_LIST[@]}"; do - if [[ $normalized == *"$keyword"* ]]; then - return 0 - fi - done - - return 1 -} - -current_day_of_week() { - printf '%(%u)T\n' -1 -} - -current_hour_24() { - printf '%(%H)T\n' -1 -} - -current_day_name() { - printf '%(%A)T\n' -1 -} +# The policy lists and the integrity-file paths are declared in pw_policy.sh, +# the only file that reads or writes them. # Stale pacman DB-lock detection/handling (get_lock_holders, # check_and_handle_db_lock) plus has_noconfirm_flag and current_epoch now live in # the shared pacman_lock_lib.sh, sourced after the integrity check below. This is # the single source of truth shared with makepkg_wrapper.sh. -# Generic function to remove installed packages matching a filter -# Args: check_function label_prefix -function remove_installed_packages_matching() { - local check_function="$1" - local label="$2" - - mapfile -t installed_names < <("$PACMAN_BIN" -Qq 2>/dev/null) - local to_remove=() - for name in "${installed_names[@]}"; do - if "$check_function" "$name"; then - to_remove+=("$name") - fi - done - - if [[ ${#to_remove[@]} -eq 0 ]]; then - return 0 - fi - - echo -e "${YELLOW}${label} cleanup:${NC} Removing packages: ${BOLD}${to_remove[*]}${NC}" >&2 - "$PACMAN_BIN" -Rns --noconfirm "${to_remove[@]}" - local rc=$? - if [[ $rc -ne 0 ]]; then - echo -e "${RED}${label} cleanup removal failed with exit code ${rc}.${NC}" >&2 - else - echo -e "${GREEN}${label} cleanup removal completed for: ${to_remove[*]}${NC}" >&2 - fi - return $rc -} - -# Cleanup: remove any installed blocked packages -function remove_installed_blocked_packages() { - remove_installed_packages_matching is_blocked_package_name "Policy" -} - -# Cleanup: remove any installed greylisted packages -function remove_installed_greylisted_packages() { - remove_installed_packages_matching is_greylisted_package_name "Greylist" -} - -# Helper: Check if this is an install command and run a filter on each package name -# Usage: check_install_for filter_func "$@" -# Returns 0 if filter_func matches any package -function check_install_for() { - local filter_func="$1" - shift - # Check if the command is an installation command - if [[ ${1:-} == "-S" || ${1:-} == "-Sy" || ${1:-} == "-Syu" || ${1:-} == "-Syyu" || ${1:-} == "-U" ]]; then - for arg in "$@"; do - # Strip repository prefix if present (like extra/ or community/) - local package_name="${arg##*/}" - if "$filter_func" "$package_name"; then - return 0 - fi - done - fi - return 1 -} - -# Function to check if user is trying to install packages that are always blocked -function check_for_always_blocked() { - check_install_for is_blocked_package_name "$@" -} - -# Helper to check if a package name is steam -# shellcheck disable=SC2329 # invoked indirectly by name (check_install_for) -function is_steam_package() { - [[ $1 == "steam" ]] -} - -# Function to check if user is trying to install steam (challenge-eligible package) -function check_for_steam() { - check_install_for is_steam_package "$@" -} - -# Function to check if current day is a weekday (after 4PM Friday until midnight Sunday) -function is_weekday() { - local day_of_week - day_of_week=$(current_day_of_week) # %u gives 1-7 (Monday is 1, Sunday is 7) - local hour - hour=$(current_hour_24) # %H gives hour in 24-hour format (00-23) - - # Monday through Thursday are always weekdays - if [[ $day_of_week -ge 1 && $day_of_week -le 4 ]]; then - return 0 # Is weekday - # Friday before 4PM is weekday, after 4PM is weekend - elif [[ $day_of_week -eq 5 ]]; then - if [[ $hour -lt 14 ]]; then - return 0 # Is weekday (Friday before 4PM) - else - return 1 # Is weekend (Friday after 4PM) - fi - # Saturday and Sunday are weekend - else - return 1 # Is weekend - fi -} - -# Unified word unscrambling challenge function -# Args: challenge_name word_length words_count timeout_seconds initial_delay_max post_delay_min post_delay_range -function run_word_challenge() { - local challenge_name="$1" - local word_length="$2" - local words_count="$3" - local timeout_seconds="$4" - local initial_delay_max="${5:-20}" - local post_delay_min="${6:-0}" - local post_delay_range="${7:-20}" - - echo -e "${YELLOW}${challenge_name} challenge will begin shortly...${NC}" - - # Initial delay - local sleep_duration=$((RANDOM % initial_delay_max)) - sleep "$sleep_duration" - - # Load words file - local script_dir words_file - script_dir="$(dirname "$(readlink -f "$0")")" - words_file="$script_dir/words.txt" - - if [[ ! -f $words_file ]]; then - echo -e "${RED}Error: words.txt file not found at $words_file${NC}" - return 1 - fi - - echo -e "${CYAN}Challenge: Words with ${word_length} letters${NC}" - - # Load random words of specified length - local -a selected_words - mapfile -t selected_words < <(grep -E "^[a-zA-Z]{$word_length}$" "$words_file" | shuf -n "$words_count") - - if [[ ${#selected_words[@]} -lt $words_count ]]; then - echo -e "${RED}Warning: Could only find ${#selected_words[@]} words of length $word_length.${NC}" - words_count=${#selected_words[@]} - if [[ $words_count -eq 0 ]]; then - echo -e "${RED}Error: No words of length $word_length found in $words_file${NC}" - return 1 - fi - fi - - # Convert to uppercase - for i in "${!selected_words[@]}"; do - selected_words[i]=$(echo "${selected_words[i]}" | tr '[:lower:]' '[:upper:]') - done - - echo -e "${CYAN}Here are ${words_count} random words. Remember them:${NC}" - - # Display words in grid - for ((i = 0; i < words_count; i++)); do - printf "${BLUE}%-15s${NC}" "${selected_words[i]}" - if (((i + 1) % 4 == 0)); then - echo "" - fi - done - - # Select and scramble a word - local target_index target_word scrambled_word - target_index=$((RANDOM % words_count)) - target_word="${selected_words[target_index]}" - scrambled_word=$(echo "$target_word" | fold -w1 | shuf | tr -d '\n') - - if [[ $scrambled_word == "$target_word" ]]; then - scrambled_word=$(echo "$target_word" | rev) - fi - - echo -e "\n${YELLOW}One of those words has been scrambled to:${NC} ${CYAN}$scrambled_word${NC}" - echo -e "${YELLOW}Unscramble the word to proceed (you have $timeout_seconds seconds):${NC}" - - # Timer display background process - ( - local start_time current_time elapsed remaining - start_time=$(current_epoch) - while true; do - current_time=$(current_epoch) - elapsed=$((current_time - start_time)) - remaining=$((timeout_seconds - elapsed)) - if [[ $remaining -le 0 ]]; then - echo -ne "\r${YELLOW}Time remaining: 0 seconds${NC} " - break - fi - echo -ne "\r${YELLOW}Time remaining: ${remaining} seconds${NC} " - sleep 1 - done - ) & - local display_pid=$! - - # Read input with timeout - local user_input read_status - read -t "$timeout_seconds" -r user_input - read_status=$? - - kill "$display_pid" 2>/dev/null - wait "$display_pid" 2>/dev/null - echo - - if [[ $read_status -ne 0 ]]; then - echo -e "${RED}Time's up! Challenge failed. The correct word was '$target_word'.${NC}" - return 1 - fi - - user_input=$(echo "$user_input" | tr '[:lower:]' '[:upper:]' | xargs) - - if [[ $user_input == "$target_word" ]]; then - echo -e "${GREEN}Correct! Proceeding with installation...${NC}" - local post_challenge_sleep=$((RANDOM % post_delay_range + post_delay_min)) - [[ $post_challenge_sleep -gt 0 ]] && sleep "$post_challenge_sleep" - return 0 - else - echo -e "${RED}Incorrect answer. Installation aborted. The correct word was '$target_word'.${NC}" - return 1 - fi -} - -# Function to prompt for solving a word unscrambling challenge (only for steam) -function prompt_for_steam_challenge() { - echo -e "${YELLOW}WARNING: You are trying to install Steam.${NC}" - - # Check if it's a weekday and block completely - if is_weekday; then - local day_name - day_name=$(current_day_name) - echo -e "${RED}Steam installation BLOCKED: Steam cannot be installed on weekdays.${NC}" - echo -e "${RED}Today is $day_name. Please try again on the weekend (Saturday or Sunday).${NC}" - return 1 - fi - - # word_length=5, words_count=160, timeout=60s, initial_delay=20, post_delay=0-20 - run_word_challenge "Weekend Steam" 5 160 60 20 0 20 -} - -function check_for_greylisted() { - check_install_for is_greylisted_package_name "$@" -} - -# Function to prompt for solving a word unscrambling challenge (for greylisted packages - always active) -function prompt_for_greylist_challenge() { - echo -e "${YELLOW}WARNING: You are trying to install a greylisted package.${NC}" - - # word_length=6, words_count=120, timeout=90s, initial_delay=30, post_delay=15-35 - run_word_challenge "Greylist" 6 120 90 30 15 20 -} - # Check for wrapper-specific commands if [[ $1 == "--help-wrapper" ]]; then show_help @@ -699,7 +98,6 @@ fi # has_noconfirm_flag, current_epoch), AFTER integrity verification so a tampered # lib (listed in the integrity manifest) is caught before it is sourced. Sourced # here — past the fast-path — so build-time non-root calls skip it entirely. -_WRAPPER_DIR="$(dirname "$(readlink -f "$0")")" if [[ -r "$_WRAPPER_DIR/pacman_lock_lib.sh" ]]; then # shellcheck source=pacman_lock_lib.sh source "$_WRAPPER_DIR/pacman_lock_lib.sh" @@ -801,120 +199,8 @@ remove_installed_blocked_packages "$@" # Also remove installed greylisted packages remove_installed_greylisted_packages "$@" -# Auto-install LeechBlock if a browser is detected -auto_install_leechblock() { - # Only check after install operations - if [[ -z ${1:-} ]] || [[ $1 != "-S"* && $1 != "-U"* ]]; then - return 0 - fi - - # List of browser packages to check for - local browsers=("firefox" "librewolf" "chromium" "brave" "vivaldi" "google-chrome" "ungoogled-chromium") - local browser_found=0 - - for browser in "${browsers[@]}"; do - if "$PACMAN_BIN" -Qq "$browser" 2>/dev/null; then - browser_found=1 - break - fi - done - - if [[ $browser_found -eq 0 ]]; then - return 0 - fi - - # Find the LeechBlock installer - local script_dir - script_dir="$(dirname "$(readlink -f "$0")")" - local leechblock_installer="" - - if [[ -f "/usr/local/share/digital_wellbeing/install_leechblock.sh" ]]; then - leechblock_installer="/usr/local/share/digital_wellbeing/install_leechblock.sh" - elif [[ -f "$script_dir/../install_leechblock.sh" ]]; then - leechblock_installer="$script_dir/../install_leechblock.sh" - fi - - if [[ -z $leechblock_installer ]]; then - echo -e "${YELLOW}Browser detected but LeechBlock installer not found.${NC}" >&2 - return 0 - fi - - # Check if LeechBlock is already installed (by looking for the extension directory) - if [[ -d "$HOME/.local/share/leechblockng" ]]; then - return 0 - fi - - echo -e "${CYAN}Browser detected. Installing LeechBlock extension for website blocking...${NC}" >&2 - - # Run the LeechBlock installer (as current user, not root) - if [[ $EUID -eq 0 && -n "${SUDO_USER:-}" ]]; then - sudo -u "$SUDO_USER" bash "$leechblock_installer" --install-firefox 2>&1 || { - echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2 - echo -e "${YELLOW} $leechblock_installer${NC}" >&2 - } - else - bash "$leechblock_installer" --install-firefox 2>&1 || { - echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2 - echo -e "${YELLOW} $leechblock_installer${NC}" >&2 - } - fi -} - auto_install_leechblock "$@" -# If VirtualBox is installed, automatically remove all VMs -auto_remove_virtualbox_vms() { - # Check if VBoxManage is available - if ! command -v VBoxManage &>/dev/null; then - return 0 - fi - - # Determine real user (wrapper may run as root via sudo) - local real_user="${SUDO_USER:-$USER}" - - # Get list of registered VMs (run as real user since VMs are per-user) - local vm_list - vm_list=$(sudo -u "$real_user" VBoxManage list vms 2>/dev/null) || return 0 - - if [[ -z $vm_list ]]; then - return 0 - fi - - echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2 - echo -e "${RED} VIRTUALBOX VMs DETECTED - AUTO-REMOVING ${NC}" >&2 - echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2 - - local vm_name - local success=0 - local failed=0 - - while IFS= read -r line; do - # VBoxManage list vms output format: "VM Name" {uuid} - vm_name="${line#\"}" - vm_name="${vm_name%%\"*}" - if [[ -z $vm_name ]]; then - continue - fi - - echo -e "${YELLOW}Removing VM: ${vm_name}${NC}" >&2 - - # Power off the VM if it's running - sudo -u "$real_user" VBoxManage controlvm "$vm_name" poweroff 2>/dev/null || true - sleep 1 - - # Unregister and delete all files - if sudo -u "$real_user" VBoxManage unregistervm "$vm_name" --delete 2>/dev/null; then - echo -e "${GREEN} Removed: ${vm_name}${NC}" >&2 - ((++success)) - else - echo -e "${RED} Failed to remove: ${vm_name}${NC}" >&2 - ((++failed)) - fi - done <<<"$vm_list" - - echo -e "${CYAN}VM removal complete: ${success} removed, ${failed} failed.${NC}" >&2 -} - auto_remove_virtualbox_vms # Display some helpful tips depending on the operation diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_challenge.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_challenge.sh new file mode 100755 index 00000000..b1532d30 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_challenge.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +current_day_of_week() { + printf '%(%u)T\n' -1 +} + +current_hour_24() { + printf '%(%H)T\n' -1 +} + +current_day_name() { + printf '%(%A)T\n' -1 +} + +# Function to check if current day is a weekday (after 4PM Friday until midnight Sunday) +function is_weekday() { + local day_of_week + day_of_week=$(current_day_of_week) # %u gives 1-7 (Monday is 1, Sunday is 7) + local hour + hour=$(current_hour_24) # %H gives hour in 24-hour format (00-23) + + # Monday through Thursday are always weekdays + if [[ $day_of_week -ge 1 && $day_of_week -le 4 ]]; then + return 0 # Is weekday + # Friday before 4PM is weekday, after 4PM is weekend + elif [[ $day_of_week -eq 5 ]]; then + if [[ $hour -lt 14 ]]; then + return 0 # Is weekday (Friday before 4PM) + else + return 1 # Is weekend (Friday after 4PM) + fi + # Saturday and Sunday are weekend + else + return 1 # Is weekend + fi +} + +# Unified word unscrambling challenge function +# Args: challenge_name word_length words_count timeout_seconds initial_delay_max post_delay_min post_delay_range +function run_word_challenge() { + local challenge_name="$1" + local word_length="$2" + local words_count="$3" + local timeout_seconds="$4" + local initial_delay_max="${5:-20}" + local post_delay_min="${6:-0}" + local post_delay_range="${7:-20}" + + echo -e "${YELLOW}${challenge_name} challenge will begin shortly...${NC}" + + # Initial delay + local sleep_duration=$((RANDOM % initial_delay_max)) + sleep "$sleep_duration" + + # Load words file + local script_dir words_file + script_dir="$(dirname "$(readlink -f "$0")")" + words_file="$script_dir/words.txt" + + if [[ ! -f $words_file ]]; then + echo -e "${RED}Error: words.txt file not found at $words_file${NC}" + return 1 + fi + + echo -e "${CYAN}Challenge: Words with ${word_length} letters${NC}" + + # Load random words of specified length + local -a selected_words + mapfile -t selected_words < <(grep -E "^[a-zA-Z]{$word_length}$" "$words_file" | shuf -n "$words_count") + + if [[ ${#selected_words[@]} -lt $words_count ]]; then + echo -e "${RED}Warning: Could only find ${#selected_words[@]} words of length $word_length.${NC}" + words_count=${#selected_words[@]} + if [[ $words_count -eq 0 ]]; then + echo -e "${RED}Error: No words of length $word_length found in $words_file${NC}" + return 1 + fi + fi + + # Convert to uppercase + for i in "${!selected_words[@]}"; do + selected_words[i]=$(echo "${selected_words[i]}" | tr '[:lower:]' '[:upper:]') + done + + echo -e "${CYAN}Here are ${words_count} random words. Remember them:${NC}" + + # Display words in grid + for ((i = 0; i < words_count; i++)); do + printf "${BLUE}%-15s${NC}" "${selected_words[i]}" + if (((i + 1) % 4 == 0)); then + echo "" + fi + done + + # Select and scramble a word + local target_index target_word scrambled_word + target_index=$((RANDOM % words_count)) + target_word="${selected_words[target_index]}" + scrambled_word=$(echo "$target_word" | fold -w1 | shuf | tr -d '\n') + + if [[ $scrambled_word == "$target_word" ]]; then + scrambled_word=$(echo "$target_word" | rev) + fi + + echo -e "\n${YELLOW}One of those words has been scrambled to:${NC} ${CYAN}$scrambled_word${NC}" + echo -e "${YELLOW}Unscramble the word to proceed (you have $timeout_seconds seconds):${NC}" + + # Timer display background process + ( + local start_time current_time elapsed remaining + start_time=$(current_epoch) + while true; do + current_time=$(current_epoch) + elapsed=$((current_time - start_time)) + remaining=$((timeout_seconds - elapsed)) + if [[ $remaining -le 0 ]]; then + echo -ne "\r${YELLOW}Time remaining: 0 seconds${NC} " + break + fi + echo -ne "\r${YELLOW}Time remaining: ${remaining} seconds${NC} " + sleep 1 + done + ) & + local display_pid=$! + + # Read input with timeout + local user_input read_status + read -t "$timeout_seconds" -r user_input + read_status=$? + + kill "$display_pid" 2>/dev/null + wait "$display_pid" 2>/dev/null + echo + + if [[ $read_status -ne 0 ]]; then + echo -e "${RED}Time's up! Challenge failed. The correct word was '$target_word'.${NC}" + return 1 + fi + + user_input=$(echo "$user_input" | tr '[:lower:]' '[:upper:]' | xargs) + + if [[ $user_input == "$target_word" ]]; then + echo -e "${GREEN}Correct! Proceeding with installation...${NC}" + local post_challenge_sleep=$((RANDOM % post_delay_range + post_delay_min)) + [[ $post_challenge_sleep -gt 0 ]] && sleep "$post_challenge_sleep" + return 0 + else + echo -e "${RED}Incorrect answer. Installation aborted. The correct word was '$target_word'.${NC}" + return 1 + fi +} + +# Function to prompt for solving a word unscrambling challenge (only for steam) +function prompt_for_steam_challenge() { + echo -e "${YELLOW}WARNING: You are trying to install Steam.${NC}" + + # Check if it's a weekday and block completely + if is_weekday; then + local day_name + day_name=$(current_day_name) + echo -e "${RED}Steam installation BLOCKED: Steam cannot be installed on weekdays.${NC}" + echo -e "${RED}Today is $day_name. Please try again on the weekend (Saturday or Sunday).${NC}" + return 1 + fi + + # word_length=5, words_count=160, timeout=60s, initial_delay=20, post_delay=0-20 + run_word_challenge "Weekend Steam" 5 160 60 20 0 20 +} + +# Function to prompt for solving a word unscrambling challenge (for greylisted packages - always active) +function prompt_for_greylist_challenge() { + echo -e "${YELLOW}WARNING: You are trying to install a greylisted package.${NC}" + + # word_length=6, words_count=120, timeout=90s, initial_delay=30, post_delay=15-35 + run_word_challenge "Greylist" 6 120 90 30 15 20 +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_cleanup.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_cleanup.sh new file mode 100755 index 00000000..96da64f4 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_cleanup.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Generic function to remove installed packages matching a filter +# Args: check_function label_prefix +function remove_installed_packages_matching() { + local check_function="$1" + local label="$2" + + mapfile -t installed_names < <("$PACMAN_BIN" -Qq 2>/dev/null) + local to_remove=() + for name in "${installed_names[@]}"; do + if "$check_function" "$name"; then + to_remove+=("$name") + fi + done + + if [[ ${#to_remove[@]} -eq 0 ]]; then + return 0 + fi + + echo -e "${YELLOW}${label} cleanup:${NC} Removing packages: ${BOLD}${to_remove[*]}${NC}" >&2 + "$PACMAN_BIN" -Rns --noconfirm "${to_remove[@]}" + local rc=$? + if [[ $rc -ne 0 ]]; then + echo -e "${RED}${label} cleanup removal failed with exit code ${rc}.${NC}" >&2 + else + echo -e "${GREEN}${label} cleanup removal completed for: ${to_remove[*]}${NC}" >&2 + fi + return $rc +} + +# Cleanup: remove any installed blocked packages +function remove_installed_blocked_packages() { + remove_installed_packages_matching is_blocked_package_name "Policy" +} + +# Cleanup: remove any installed greylisted packages +function remove_installed_greylisted_packages() { + remove_installed_packages_matching is_greylisted_package_name "Greylist" +} + +# Auto-install LeechBlock if a browser is detected +auto_install_leechblock() { + # Only check after install operations + if [[ -z ${1:-} ]] || [[ $1 != "-S"* && $1 != "-U"* ]]; then + return 0 + fi + + # List of browser packages to check for + local browsers=("firefox" "librewolf" "chromium" "brave" "vivaldi" "google-chrome" "ungoogled-chromium") + local browser_found=0 + + for browser in "${browsers[@]}"; do + if "$PACMAN_BIN" -Qq "$browser" 2>/dev/null; then + browser_found=1 + break + fi + done + + if [[ $browser_found -eq 0 ]]; then + return 0 + fi + + # Find the LeechBlock installer + local script_dir + script_dir="$(dirname "$(readlink -f "$0")")" + local leechblock_installer="" + + if [[ -f "/usr/local/share/digital_wellbeing/install_leechblock.sh" ]]; then + leechblock_installer="/usr/local/share/digital_wellbeing/install_leechblock.sh" + elif [[ -f "$script_dir/../install_leechblock.sh" ]]; then + leechblock_installer="$script_dir/../install_leechblock.sh" + fi + + if [[ -z $leechblock_installer ]]; then + echo -e "${YELLOW}Browser detected but LeechBlock installer not found.${NC}" >&2 + return 0 + fi + + # Check if LeechBlock is already installed (by looking for the extension directory) + if [[ -d "$HOME/.local/share/leechblockng" ]]; then + return 0 + fi + + echo -e "${CYAN}Browser detected. Installing LeechBlock extension for website blocking...${NC}" >&2 + + # Run the LeechBlock installer (as current user, not root) + if [[ $EUID -eq 0 && -n "${SUDO_USER:-}" ]]; then + sudo -u "$SUDO_USER" bash "$leechblock_installer" --install-firefox 2>&1 || { + echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2 + echo -e "${YELLOW} $leechblock_installer${NC}" >&2 + } + else + bash "$leechblock_installer" --install-firefox 2>&1 || { + echo -e "${YELLOW}LeechBlock auto-install failed. Please install manually:${NC}" >&2 + echo -e "${YELLOW} $leechblock_installer${NC}" >&2 + } + fi +} + +# If VirtualBox is installed, automatically remove all VMs +auto_remove_virtualbox_vms() { + # Check if VBoxManage is available + if ! command -v VBoxManage &>/dev/null; then + return 0 + fi + + # Determine real user (wrapper may run as root via sudo) + local real_user="${SUDO_USER:-$USER}" + + # Get list of registered VMs (run as real user since VMs are per-user) + local vm_list + vm_list=$(sudo -u "$real_user" VBoxManage list vms 2>/dev/null) || return 0 + + if [[ -z $vm_list ]]; then + return 0 + fi + + echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2 + echo -e "${RED} VIRTUALBOX VMs DETECTED - AUTO-REMOVING ${NC}" >&2 + echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2 + + local vm_name + local success=0 + local failed=0 + + while IFS= read -r line; do + # VBoxManage list vms output format: "VM Name" {uuid} + vm_name="${line#\"}" + vm_name="${vm_name%%\"*}" + if [[ -z $vm_name ]]; then + continue + fi + + echo -e "${YELLOW}Removing VM: ${vm_name}${NC}" >&2 + + # Power off the VM if it's running + sudo -u "$real_user" VBoxManage controlvm "$vm_name" poweroff 2>/dev/null || true + sleep 1 + + # Unregister and delete all files + if sudo -u "$real_user" VBoxManage unregistervm "$vm_name" --delete 2>/dev/null; then + echo -e "${GREEN} Removed: ${vm_name}${NC}" >&2 + ((++success)) + else + echo -e "${RED} Failed to remove: ${vm_name}${NC}" >&2 + ((++failed)) + fi + done <<<"$vm_list" + + echo -e "${CYAN}VM removal complete: ${success} removed, ${failed} failed.${NC}" >&2 +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_guard.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_guard.sh new file mode 100755 index 00000000..6ea5ce31 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_guard.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Determine if this invocation may perform a transaction (upgrade/install/remove) +needs_unlock() { + # If args include -S (install/upgrade), -U (local install), or -R (remove), we unlock + # Also include -Su/-Syu/-Syuu when -S is part of the combined flag + for arg in "$@"; do + case "$arg" in + -S*) + return 0 + ;; + -U) + return 0 + ;; + -R) + return 0 + ;; + --sync) + return 0 + ;; + --upgrade) + return 0 + ;; + --remove) + return 0 + ;; + esac + done + return 1 +} + +pacman_hooks_manage_guard_lib() { + local pre_hook="/etc/pacman.d/hooks/10-guard-lib-unlock-all.hook" + local post_hook="/etc/pacman.d/hooks/90-guard-lib-relock-all.hook" + local pre_exec="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh" + local post_exec="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh" + + if [[ ! -f $pre_hook || ! -f $post_hook ]]; then + return 1 + fi + + grep -Fq "$pre_exec" "$pre_hook" && grep -Fq "$post_exec" "$post_hook" +} + +should_use_wrapper_guard_lib_fallback() { + if ! needs_unlock "$@"; then + return 1 + fi + + if pacman_hooks_manage_guard_lib; then + return 1 + fi + + return 0 +} + +# Run guard-lib's own generic unlock-all/relock-all scripts directly if +# pacman's own hooks for them are missing (e.g. hooks disabled/misconfigured). +# These cover every registered file-guard instance (hosts, nsswitch, +# resolved, shutdown-schedule, ...), not just /etc/hosts. +pre_unlock_guard_lib() { + local pre="/etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh" + if [[ -x $pre ]]; then + echo -e "${CYAN}[guard-lib] Preparing guarded files for transaction...${NC}" >&2 + /bin/bash "$pre" || true + fi +} + +post_relock_guard_lib() { + local post="/etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh" + if [[ -x $post ]]; then + /bin/bash "$post" || true + echo -e "${CYAN}[guard-lib] Protections re-applied to guarded files.${NC}" >&2 + fi +} + +# Ensure periodic system services (timer/monitor) are set up; if not, trigger setup +ensure_periodic_maintenance() { + # Only proceed if systemd/systemctl is available + if ! command -v systemctl >/dev/null 2>&1; then + return 0 + fi + + local timer_unit="periodic-system-maintenance.timer" + local startup_unit="periodic-system-startup.service" + local monitor_unit="hosts-file-monitor.service" + local needs_setup=0 + + # Timer should be enabled and active + systemctl --quiet is-enabled "$timer_unit" || needs_setup=1 + systemctl --quiet is-active "$timer_unit" || needs_setup=1 + + # Monitor should be enabled and active + systemctl --quiet is-enabled "$monitor_unit" || needs_setup=1 + systemctl --quiet is-active "$monitor_unit" || needs_setup=1 + + # Startup service should be enabled (it’s oneshot and may not be active except at boot) + systemctl --quiet is-enabled "$startup_unit" || needs_setup=1 + + if [[ $needs_setup -eq 0 ]]; then + return 0 + fi + + echo -e "${YELLOW}Periodic maintenance services missing or inactive. Running setup...${NC}" >&2 + + # Try to locate setup_periodic_system.sh. The installed wrapper lives in + # /usr/local/bin (so $self_dir won't contain it) and, for real transactions, + # runs as root under sudo (so $HOME points at /root). Resolve the invoking + # user's home via SUDO_USER and probe the known repo locations. + local setup_script="" + local self_dir real_user real_home + self_dir="$(dirname "$(readlink -f "$0")")" + real_user="${SUDO_USER:-${USER:-$(id -un)}}" + real_home="$(getent passwd "$real_user" 2>/dev/null | cut -d: -f6)" + [[ -z $real_home ]] && real_home="$HOME" + + local -a setup_candidates=( + "$self_dir/setup_periodic_system.sh" + "$real_home/testsAndMisc/linux_configuration/scripts/periodic_background/setup_periodic_system.sh" + "$real_home/linux_configuration/scripts/periodic_background/setup_periodic_system.sh" + "$real_home/linux-configuration/scripts/periodic_background/setup_periodic_system.sh" + ) + local candidate + for candidate in "${setup_candidates[@]}"; do + if [[ -f $candidate ]]; then + setup_script="$candidate" + break + fi + done + + if [[ -n $setup_script ]]; then + if [[ $EUID -ne 0 ]]; then + sudo bash "$setup_script" + else + bash "$setup_script" + fi + echo -e "${CYAN}Tip:${NC} To disable these later:" >&2 + echo " sudo systemctl disable periodic-system-maintenance.timer" >&2 + echo " sudo systemctl disable periodic-system-startup.service" >&2 + echo " sudo systemctl disable hosts-file-monitor.service" >&2 + else + echo -e "${RED}Could not locate setup_periodic_system.sh to configure services automatically.${NC}" >&2 + fi +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_policy.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_policy.sh new file mode 100755 index 00000000..d976a969 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_policy.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Declared here rather than in the entry script: this is the only file that +# reads or writes them, and a cross-file global the sourcing script cannot +# see is a genuine SC2034 (the hook runs shellcheck without -x). +declare -a BLOCKED_KEYWORDS_LIST=() +declare -a WHITELISTED_NAMES_LIST=() +declare -a GREYLISTED_KEYWORDS_LIST=() +POLICY_LISTS_LOADED=0 +INTEGRITY_DIR="/var/lib/pacman-wrapper" +INTEGRITY_FILE="${INTEGRITY_DIR}/policy.sha256" + +# Verify integrity of policy files +verify_policy_integrity() { + if [[ ! -f $INTEGRITY_FILE ]]; then + echo -e "${RED}SECURITY WARNING: Policy integrity file missing!${NC}" >&2 + echo -e "${RED}The pacman wrapper may have been tampered with.${NC}" >&2 + echo -e "${RED}Please reinstall the wrapper using: sudo install_pacman_wrapper.sh${NC}" >&2 + return 1 + fi + + local script_dir + script_dir="$(dirname "$(readlink -f "$0")")" + local blocked_file="$script_dir/pacman_blocked_keywords.txt" + local greylist_file="$script_dir/pacman_greylist.txt" + local whitelist_file="$script_dir/pacman_whitelist.txt" + + # Verify checksums + local failed=0 + while IFS= read -r line; do + local expected_hash expected_file + expected_hash=$(echo "$line" | awk '{print $1}') + expected_file=$(echo "$line" | awk '{print $2}') + + if [[ -f $expected_file ]]; then + local actual_hash + actual_hash=$(sha256sum "$expected_file" 2>/dev/null | awk '{print $1}') + if [[ $actual_hash != "$expected_hash" ]]; then + echo -e "${RED}SECURITY WARNING: Policy file integrity check failed for $expected_file${NC}" >&2 + failed=1 + fi + fi + done <"$INTEGRITY_FILE" + + if [[ $failed -eq 1 ]]; then + echo -e "${RED}CRITICAL: Policy files have been tampered with!${NC}" >&2 + echo -e "${RED}This could be an attempt to bypass security restrictions.${NC}" >&2 + echo -e "${RED}Wrapper operation DENIED. Please reinstall using: sudo install_pacman_wrapper.sh${NC}" >&2 + return 1 + fi + + return 0 +} + +# shellcheck disable=SC2329 # invoked indirectly, see is_blocked_package_name/is_greylisted_package_name callers below +load_policy_lists() { + if [[ $POLICY_LISTS_LOADED -eq 1 ]]; then + return + fi + + local script_dir + script_dir="$(dirname "$(readlink -f "$0")")" + local blocked_file="$script_dir/pacman_blocked_keywords.txt" + local whitelist_file="$script_dir/pacman_whitelist.txt" + local greylist_file="$script_dir/pacman_greylist.txt" + + read_policy_list_file() { + local file_path="$1" + local -n target_array="$2" + local line="" + + target_array=() + while IFS= read -r line || [[ -n $line ]]; do + line="${line%$'\r'}" + if [[ $line =~ ^[[:space:]]*(#|$) ]]; then + continue + fi + target_array+=("${line,,}") + done <"$file_path" + } + + if [[ -f $blocked_file ]]; then + read_policy_list_file "$blocked_file" BLOCKED_KEYWORDS_LIST + else + BLOCKED_KEYWORDS_LIST=() + echo -e "${YELLOW}Warning:${NC} Missing blocked keywords file at $blocked_file" >&2 + fi + + if [[ -f $whitelist_file ]]; then + read_policy_list_file "$whitelist_file" WHITELISTED_NAMES_LIST + else + WHITELISTED_NAMES_LIST=() + fi + + if [[ -f $greylist_file ]]; then + read_policy_list_file "$greylist_file" GREYLISTED_KEYWORDS_LIST + else + GREYLISTED_KEYWORDS_LIST=() + fi + + POLICY_LISTS_LOADED=1 +} + +# Helper: strip a pacman package-file suffix (---.pkg.tar.) +# down to the bare package name, if present. `pacman -U ` (what `yay` +# always uses to install a package it just built locally) passes a filename +# like "ungoogled-chromium-bin-150.0.7871.186-1-x86_64.pkg.tar.zst", not the +# bare name — without this, an exact-match whitelist entry for +# "ungoogled-chromium-bin" could never match a `-U` install and would always +# fall through to the (correctly still-substring) blocked-keywords check. +# Leaves package names without this suffix (the `-S` case) untouched. +# shellcheck disable=SC2329 # invoked indirectly by name (is_blocked_package_name) +function strip_pkgfile_suffix() { + local name="$1" + if [[ $name =~ ^(.+)-[^-]+-[0-9]+-(x86_64|any|i686|aarch64)\.pkg\.tar\.(zst|xz|gz|bz2|lrz|lzo|Z)$ ]]; then + printf '%s' "${BASH_REMATCH[1]}" + else + printf '%s' "$name" + fi +} + +# Helper: return 0 if the given package name is blocked by policy +# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for) +function is_blocked_package_name() { + load_policy_lists + local normalized="${1,,}" + local bare_name + bare_name="$(strip_pkgfile_suffix "$normalized")" + + for allowed in "${WHITELISTED_NAMES_LIST[@]}"; do + if [[ $normalized == "$allowed" || $bare_name == "$allowed" ]]; then + return 1 + fi + done + + for keyword in "${BLOCKED_KEYWORDS_LIST[@]}"; do + if [[ $normalized == *"$keyword"* ]]; then + return 0 + fi + done + + return 1 +} + +# Helper: return 0 if the given package name is greylisted (challenge required) +# shellcheck disable=SC2329 # invoked indirectly by name (remove_installed_packages_matching, check_install_for) +function is_greylisted_package_name() { + load_policy_lists + local normalized="${1,,}" + + for keyword in "${GREYLISTED_KEYWORDS_LIST[@]}"; do + if [[ $normalized == *"$keyword"* ]]; then + return 0 + fi + done + + return 1 +} + +# Helper: Check if this is an install command and run a filter on each package name +# Usage: check_install_for filter_func "$@" +# Returns 0 if filter_func matches any package +function check_install_for() { + local filter_func="$1" + shift + # Check if the command is an installation command + if [[ ${1:-} == "-S" || ${1:-} == "-Sy" || ${1:-} == "-Syu" || ${1:-} == "-Syyu" || ${1:-} == "-U" ]]; then + for arg in "$@"; do + # Strip repository prefix if present (like extra/ or community/) + local package_name="${arg##*/}" + if "$filter_func" "$package_name"; then + return 0 + fi + done + fi + return 1 +} + +# Function to check if user is trying to install packages that are always blocked +function check_for_always_blocked() { + check_install_for is_blocked_package_name "$@" +} + +# Helper to check if a package name is steam +# shellcheck disable=SC2329 # invoked indirectly by name (check_install_for) +function is_steam_package() { + [[ $1 == "steam" ]] +} + +# Function to check if user is trying to install steam (challenge-eligible package) +function check_for_steam() { + check_install_for is_steam_package "$@" +} + +function check_for_greylisted() { + check_install_for is_greylisted_package_name "$@" +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_ui.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_ui.sh new file mode 100755 index 00000000..95dcf0b5 --- /dev/null +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pw_ui.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Helpers sourced by the entry script. + +# Function to display help +function show_help() { + echo -e "${BOLD}Pacman Wrapper Help${NC}" + echo "This wrapper adds helpful features while preserving all pacman functionality." + 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 +function display_operation() { + case "$1" in + -S) + echo -e "${BLUE}Installing packages...${NC}" >&2 + ;; + -Sy) + echo -e "${BLUE}Installing packages...${NC}" >&2 + ;; + -S\ *) + echo -e "${BLUE}Installing packages...${NC}" >&2 + ;; + -Syu | -Syyu) + echo -e "${BLUE}Updating system...${NC}" >&2 + ;; + -R) + echo -e "${YELLOW}Removing packages...${NC}" >&2 + ;; + -Rs) + echo -e "${YELLOW}Removing packages...${NC}" >&2 + ;; + -Rns) + echo -e "${YELLOW}Removing packages...${NC}" >&2 + ;; + -R\ *) + echo -e "${YELLOW}Removing packages...${NC}" >&2 + ;; + -Ss) + echo -e "${CYAN}Searching for packages...${NC}" >&2 + ;; + -Ss\ *) + echo -e "${CYAN}Searching for packages...${NC}" >&2 + ;; + -Q) + echo -e "${CYAN}Querying package database...${NC}" >&2 + ;; + -Qs) + echo -e "${CYAN}Querying package database...${NC}" >&2 + ;; + -Qi) + echo -e "${CYAN}Querying package database...${NC}" >&2 + ;; + -Ql) + echo -e "${CYAN}Querying package database...${NC}" >&2 + ;; + -Q\ *) + echo -e "${CYAN}Querying package database...${NC}" >&2 + ;; + -U) + echo -e "${BLUE}Installing local packages...${NC}" >&2 + ;; + -U\ *) + echo -e "${BLUE}Installing local packages...${NC}" >&2 + ;; + -Scc) + echo -e "${YELLOW}Cleaning package cache...${NC}" >&2 + ;; + *) + echo -e "${CYAN}Executing pacman command...${NC}" >&2 + ;; + esac +}