diff --git a/bin/omarchy-hook b/bin/omarchy-hook index 8c2a59d99c1..499cdf14cfa 100755 --- a/bin/omarchy-hook +++ b/bin/omarchy-hook @@ -11,6 +11,18 @@ if (( $# < 1 )); then fi HOOK=$1 + +# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set, +# font-set). The name becomes a filename under the hooks directory. A slash +# would turn it into directory levels, and a bare `.` or `..` would point bash +# at the directory itself or its parent. Refuse those rather than follow them. +# Dots inside a name (a..b) are fine; once slashes are out, only the whole +# name being `.` or `..` can leave the directory. +if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then + echo "Invalid hook name: $HOOK" >&2 + exit 2 +fi + HOOK_PATH="$HOME/.config/omarchy/hooks/$1" HOOK_DIR="$HOOK_PATH.d" shift diff --git a/bin/omarchy-hook-install b/bin/omarchy-hook-install index 7c53648259b..6e954bd158b 100755 --- a/bin/omarchy-hook-install +++ b/bin/omarchy-hook-install @@ -15,6 +15,17 @@ fi HOOK_TYPE=$1 HOOK_FILE=$2 + +# Hook types are the same labels omarchy-hook runs (post-update, theme-set). +# The type becomes a directory under the hooks directory. A slash would turn +# it into directory levels. A bare `.` or `..` is a name the runner already +# refuses, so installing under it would write a hook nothing can run. Refuse +# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine. +if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then + echo "Invalid hook name: $HOOK_TYPE" >&2 + exit 2 +fi + HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d" HOOK_NAME=$(basename "$HOOK_FILE") HOOK_PATH="$HOOK_DIR/$HOOK_NAME" diff --git a/bin/omarchy-install-dev-env b/bin/omarchy-install-dev-env index 263fd755637..03577198d6f 100755 --- a/bin/omarchy-install-dev-env +++ b/bin/omarchy-install-dev-env @@ -12,37 +12,10 @@ if [[ -z $1 ]]; then fi install_php() { - omarchy-pkg-add php composer php-sqlite xdebug + mise tool-alias set php github:nunomaduro/static-php-builds + mise use --global php@latest - # Install Path for Composer - if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then - echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc" - source "$HOME/.bashrc" - echo "Added Composer global bin directory to PATH." - else - echo "Composer global bin directory already in PATH." - fi - - # Enable some extensions - local php_ini_path="/etc/php/php.ini" - local extensions_to_enable=( - "bcmath" - "intl" - "iconv" - "openssl" - "pdo_sqlite" - "pdo_mysql" - ) - - # Enable Xdebug - sudo sed -i \ - -e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \ - -e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \ - /etc/php/conf.d/xdebug.ini - - for ext in "${extensions_to_enable[@]}"; do - sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path" - done + mise x php -- composer global config bin-dir "$HOME/.local/bin" } install_node() { @@ -84,7 +57,7 @@ laravel) echo -e "Installing PHP and Laravel...\n" install_php install_node - composer global require laravel/installer + mise x php -- composer global require laravel/installer echo -e "\nYou can now run: laravel new myproject" ;; symfony) diff --git a/bin/omarchy-install-service-1password b/bin/omarchy-install-service-1password index f59cab9257c..fc7238a6dd8 100755 --- a/bin/omarchy-install-service-1password +++ b/bin/omarchy-install-service-1password @@ -28,7 +28,7 @@ echo "Installing 1Password extension for Chromium..." install_chromium_extension echo "Opening 1Password..." -uwsm-app -- 1password >/dev/null 2>&1 & +setsid uwsm-app -- 1password >/dev/null 2>&1 & echo "" echo "1Password has been installed. Restart Chromium to load the browser extension." diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index a24e7f9e294..1be245b79a7 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -10,6 +10,11 @@ if [[ -z $1 ]]; then fi remove_php() { + mise uninstall php --all + mise rm -g php + mise tool-alias unset php + + # PHP from before it moved to mise omarchy-pkg-drop php composer php-sqlite xdebug } @@ -46,7 +51,9 @@ php) ;; laravel) echo -e "Removing Laravel...\n" - composer global remove laravel/installer 2>/dev/null || true + mise x php -- composer global remove laravel/installer 2>/dev/null || true + + rm -f "$HOME/.local/bin/laravel" "$HOME/.config/composer/vendor/bin/laravel" ;; symfony) echo -e "Removing Symfony CLI...\n" diff --git a/bin/omarchy-state b/bin/omarchy-state index 4fda5b3a63a..3b2f5e17e40 100755 --- a/bin/omarchy-state +++ b/bin/omarchy-state @@ -21,6 +21,19 @@ if [[ -z $STATE_NAME ]]; then fi case "$COMMAND" in -set) touch "$STATE_DIR/$STATE_NAME" ;; +set) + # State names are fixed labels (reboot-required, restart-*-required). The + # name becomes a filename under the state directory. A slash would turn it + # into directory levels, and a bare `.` or `..` would touch the directory + # itself or its parent. Refuse those. Dots inside a name (a..b) are fine; + # once slashes are out, only the whole name being `.` or `..` can leave the + # directory. clear needs no such guard: find -name matches basenames only, + # so a pattern can never walk out of the directory. + if [[ $STATE_NAME == */* || $STATE_NAME == "." || $STATE_NAME == ".." ]]; then + echo "Invalid state name: $STATE_NAME" >&2 + exit 2 + fi + touch "$STATE_DIR/$STATE_NAME" + ;; clear) find "$STATE_DIR" -maxdepth 1 -type f -name "$STATE_NAME" -delete ;; esac diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca40110a..43348e5a4d0 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -297,19 +297,34 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# Both the staged system and the retained baseline must lose the old hashes. +scrub_factory_accounts() { + local root="$1" user users + + users=$(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$root/etc/passwd") || return 1 + for user in $users; do + userdel --root "$root" "$user" 2>>"$LOG_FILE" || return 1 + rm -rf "${root:?}/home/$user" || return 1 + done + + # passwd --lock preserves the hash. Replace it, then remove the backups + # that userdel and usermod leave behind. + usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-,subuid-,subgid-} +} + # Remove the seller's account material and machine identity from the retained # @factory baseline so it can neither be mounted for recovery nor restore the # seller's account on a future reset. Idempotent (a scrubbed baseline has no # uid>=1000 accounts left to remove). sanitize_factory_baseline() { - local factory="$1" user + local factory="$1" btrfs property set -ts "$factory" ro false - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do - userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true - rm -rf "${factory:?}/home/$user" - done - passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true + if ! scrub_factory_accounts "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not remove account credentials from the factory baseline (see $LOG_FILE)" + fi rm -f "$factory"/etc/ssh/ssh_host_* rm -f "$factory"/etc/NetworkManager/system-connections/* rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" @@ -337,17 +352,13 @@ stage_full_reset() { rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" - # A factory snapshot from a normal (normal) install contains the original + # A factory snapshot from a normal install contains the original # user account; first-boot setup must start from none. A leftover account # would keep its password hash and group memberships (including wheel), so # failure here has to abort the reset, not be shrugged off. - local user - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do - log "Removing user $user from the factory system" - userdel --root "$next" "$user" 2>>"$LOG_FILE" || - fail "could not remove user $user from the factory system (see $LOG_FILE)" - done - passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true + log "Removing account credentials from the factory system" + scrub_factory_accounts "$next" || + fail "could not remove account credentials from the factory system (see $LOG_FILE)" # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and diff --git a/bin/omarchy-update b/bin/omarchy-update index dc7f9f6a17d..f89b5810446 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -1,6 +1,7 @@ #!/bin/bash # omarchy:summary=Update Omarchy and system packages +# omarchy:alias=omarchy up # omarchy:args=[-y] # omarchy:examples=omarchy update | omarchy update -y # omarchy:requires-sudo=true diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 3323dd44bd1..56314b5b2a2 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -278,8 +278,8 @@ "install.development.javascript.node": {"icon":"","label":"Node.js","disabled":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env node'"}, "install.development.javascript.bun": {"icon":"","label":"Bun","disabled":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env bun'"}, "install.development.javascript.deno": {"icon":"","label":"Deno","disabled":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env deno'"}, - "install.development.php.php": {"icon":"","label":"PHP","disabled":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, - "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, + "install.development.php.php": {"icon":"","label":"PHP","disabled":"[[ -d $HOME/.local/share/mise/installs/php ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, + "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.local/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, "install.development.php.symfony": {"icon":"","label":"Symfony","disabled":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env symfony'"}, "install.development.elixir.elixir": {"icon":"","label":"Elixir","disabled":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env elixir'"}, "install.development.elixir.phoenix": {"icon":"","label":"Phoenix","disabled":"compgen -G \"$HOME/.mix/archives/phx_new*\"","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env phoenix'"}, @@ -345,8 +345,8 @@ "remove.development.javascript.node": {"icon":"","label":"Node.js","when":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env node'"}, "remove.development.javascript.bun": {"icon":"","label":"Bun","when":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env bun'"}, "remove.development.javascript.deno": {"icon":"","label":"Deno","when":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env deno'"}, - "remove.development.php.php": {"icon":"","label":"PHP","when":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, - "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, + "remove.development.php.php": {"icon":"","label":"PHP","when":"[[ -d $HOME/.local/share/mise/installs/php ]] || omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, + "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.local/bin/laravel || -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, "remove.development.php.symfony": {"icon":"","label":"Symfony","when":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env symfony'"}, "remove.development.elixir.elixir": {"icon":"","label":"Elixir","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env elixir'"}, "remove.development.elixir.phoenix": {"icon":"","label":"Phoenix","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env phoenix'"}, diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index 9ce5249fef0..75ca0916c43 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -30,6 +30,11 @@ if [[ -n $NODE_PACKAGE_DIR ]]; then mkdir -p "$NODE_INSTALL_DIR" tar -xzf "$NODE_TARBALL" --strip-components=1 -C "$NODE_INSTALL_DIR" mise use -g node@"$NODE_VERSION" + + # That pinned the exact bundled version, which would exempt Node from + # mise up forever. Loosen it to latest, like an online install gets: + # mise resolves latest to the installed version while offline. + mise config set tools.node latest --file "$HOME/.config/mise/config.toml" fi else mise use -g node@latest diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh new file mode 100644 index 00000000000..ed2bb69757d --- /dev/null +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if (( EUID != 0 )); then + if unshare --user --map-root-user true 2>/dev/null; then + exec unshare --user --map-root-user bash "$0" + fi + pass "no unprivileged user namespace; skipping factory account cleanup" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Load the production functions without self-elevation or the reset entrypoint. +awk ' + /^[a-z_]+\(\) \{/ { copying = 1 } + copying { print } + /^}/ { copying = 0 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$test_tmp/functions" + +cat >"$test_tmp/reset" <<'SH' +#!/bin/bash +set -euo pipefail +source "$1/functions" +TOP_MNT="$2" +NEXT_NAME=@omarchy-reset-next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE="$TOP_MNT/reset.log" + +log() { printf '%s\n' "$1" >>"$LOG_FILE"; } +fail() { log "$1"; exit 1; } + +# Account tools are real. Only snapshots, boot rebuilding, and system services +# are replaced: all writes stay inside this test's disposable directory. +btrfs() { + if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then + mkdir -p "$4" + cp -a "$3/." "$4/" + elif [[ $1 == "property" ]]; then + printf '%s\n' "$6" >"$4/read-only" + else + return 1 + fi +} +systemd-id128() { printf '%032d\n' 1; } +install_provisioning_units() { :; } +encrypted_install() { return 1; } +rebuild_next_boot() { touch "$TOP_MNT/rebuilt"; } +sync() { :; } + +userdel() { + [[ ${FAIL_COMMAND:-} == "userdel" && $2 == "$FAIL_ROOT" ]] && return 42 + command userdel "$@" +} +usermod() { + [[ ${FAIL_COMMAND:-} == "usermod" && $2 == "$FAIL_ROOT" ]] && return 42 + command usermod "$@" +} +rm() { + [[ ${FAIL_COMMAND:-} == "rm" && $* == *"$FAIL_ROOT/etc/shadow-"* ]] && return 42 + command rm "$@" +} + +stage_full_reset +SH + +make_fixture() { + local top="$1" root_hash="${2:-original-root-hash}" + local factory="$top/@factory" + mkdir -p "$top/@" "$factory/etc" "$factory/home/seller" \ + "$factory/usr/bin" "$factory/usr/share/omarchy/install/provisioning" \ + "$factory/var/lib/omarchy/provisioning/packages" + touch "$top/@/old-system" "$factory/home/seller/private-file" \ + "$factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" \ + "$factory/var/lib/omarchy/provisioning/packages/node-v0.tar.gz" + printf '#!/bin/bash\n' >"$factory/usr/bin/omarchy-provision-owner" + chmod +x "$factory/usr/bin/omarchy-provision-owner" + printf 'true\n' >"$factory/read-only" + cat >"$factory/etc/passwd" <<'EOF' +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/:/usr/bin/nologin +seller:x:1000:1000:Seller:/home/seller:/bin/bash +EOF + printf 'root:%s:20000:0:99999:7:::\ndaemon:*:20000:0:99999:7:::\nseller:original-user-hash:20000:0:99999:7:::\n' \ + "$root_hash" >"$factory/etc/shadow" + printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" + printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" + printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + printf 'seller:100000:65536\n' >"$factory/etc/subuid" + printf 'seller:100000:65536\n' >"$factory/etc/subgid" + chmod 600 "$factory/etc/"{shadow,gshadow} + for file in passwd shadow group gshadow subuid subgid; do + cp "$factory/etc/$file" "$factory/etc/$file-" + done +} + +assert_scrubbed() { + local root="$1" file + [[ $(awk -F: '$1 == "root" { print $2 }' "$root/etc/shadow") == "!" ]] || + fail "reset erases the root hash while keeping the account locked" + ! grep -q 'original-.*-hash\|seller' "$root/etc/"{passwd,shadow,group,gshadow} || + fail "reset removes seller account credentials and group membership" + [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" + grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" + [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" + for file in passwd shadow group gshadow subuid subgid; do + [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" + done +} + +for scenario in normal locked; do + top="$test_tmp/$scenario" + if [[ $scenario == "locked" ]]; then + make_fixture "$top" '!' + else + make_fixture "$top" + fi + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset stages successfully" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + [[ $(cat "$top/@factory/read-only") == "true" ]] || fail "baseline returns to read-only" + [[ -f $top/@/var/lib/omarchy/provisioning/pending && -f $top/rebuilt ]] || + fail "reset reaches provisioning after cleanup" + + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset can be repeated" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + pass "$scenario reset scrubs both roots, preserves service accounts, and can be repeated" +done + +for target in @omarchy-reset-next @factory; do + for command in userdel usermod rm; do + top="$test_tmp/fail-$target-$command" + make_fixture "$top" + if FAIL_COMMAND="$command" FAIL_ROOT="$top/$target" bash "$test_tmp/reset" "$test_tmp" "$top"; then + fail "reset accepted failed $command in $target" + fi + [[ -f $top/@/old-system && ! -e $top/rebuilt ]] || + fail "failed cleanup must not activate or rebuild the reset system" + [[ $(cat "$top/@factory/read-only") == "true" ]] || + fail "failed cleanup must leave the baseline read-only" + pass "failed $command in $target aborts reset before activation" + done +done diff --git a/test/shell.d/hook-state-name-guard-test.sh b/test/shell.d/hook-state-name-guard-test.sh new file mode 100644 index 00000000000..0157202b4d1 --- /dev/null +++ b/test/shell.d/hook-state-name-guard-test.sh @@ -0,0 +1,158 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +fake_home="$work_dir/home" +mkdir -p "$fake_home/.config/omarchy/hooks" "$fake_home/.local/state/omarchy" + +# --- omarchy-hook -------------------------------------------------------------- + +# A hook name is a label, not a path. One carrying a slash, or one that is a +# bare `.` or `..`, would run a script from outside the hooks directory. + +cat >"$fake_home/.config/omarchy/hooks/test-hook" <<'SH' +touch "$HOME/hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" test-hook +[[ -f $fake_home/hook-ran ]] || + fail "omarchy hook runs a named hook from the hooks directory" +pass "omarchy hook runs a named hook from the hooks directory" + +# Dots inside a name are not a path. a..b stays inside the hooks directory. +cat >"$fake_home/.config/omarchy/hooks/a..b" <<'SH' +touch "$HOME/dotted-hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" a..b +[[ -f $fake_home/dotted-hook-ran ]] || + fail "omarchy hook accepts a hook name with dots in the middle" +pass "omarchy hook accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook" "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook refuses a hook name of $name" "exit: $status" + pass "omarchy hook refuses a hook name of $name" +done + +# This file sits where a name of ../../evil would resolve: hooks/../.. is +# ~/.config. +cat >"$fake_home/.config/evil" <<'SH' +touch "$HOME/escape-ran" +SH +chmod +x "$fake_home/.config/evil" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "../../evil" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/escape-ran ]] || + fail "omarchy hook runs nothing when it refuses the name" +pass "omarchy hook refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a slash" "exit: $status" +pass "omarchy hook refuses a hook name with a slash" + +# --- omarchy-hook-install ------------------------------------------------------ + +# The installer joins the type into ~/.config/omarchy/hooks/.d before +# mkdir/cp. The runner already refuses a slashed type; install must too, or a +# name the runner will not run still lands on disk. + +source_hook="$work_dir/source-hook" +cat >"$source_hook" <<'SH' +#!/bin/bash +true +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" post-update "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/post-update.d/source-hook ]] || + fail "omarchy hook install still installs a named hook" +pass "omarchy hook install still installs a named hook" + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" a..b "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/a..b.d/source-hook ]] || + fail "omarchy hook install accepts a hook name with dots in the middle" +pass "omarchy hook install accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "$name" "$source_hook" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook install refuses a hook name of $name" "exit: $status" + [[ ! -e $fake_home/.config/omarchy/hooks/${name}.d ]] || + fail "omarchy hook install creates no directory for a hook name of $name" + pass "omarchy hook install refuses a hook name of $name" +done + +# hooks/../../evil.d is ~/.config/evil.d. The guard must fire before mkdir. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "../../evil" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.config/evil.d ]] || + fail "omarchy hook install creates nothing outside the hooks directory" +pass "omarchy hook install refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "sub/dir" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a slash" "exit: $status" +[[ ! -e $fake_home/.config/omarchy/hooks/sub ]] || + fail "omarchy hook install creates no nested directory from a slashed name" +pass "omarchy hook install refuses a hook name with a slash" + +# --- omarchy-state ------------------------------------------------------------- + +state_dir="$fake_home/.local/state/omarchy" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set reboot-required +[[ -f $state_dir/reboot-required ]] || + fail "omarchy state set still creates a plain state file" +pass "omarchy state set still creates a plain state file" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set v1..2 +[[ -f $state_dir/v1..2 ]] || + fail "omarchy state set accepts a state name with dots in the middle" +pass "omarchy state set accepts a state name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-state" set "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy state set refuses a state name of $name" "exit: $status" + pass "omarchy state set refuses a state name of $name" +done + +# state/../.. is ~/.local. The guard must fire before touch gets there. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "../../escape" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.local/escape ]] || + fail "omarchy state set creates nothing outside the state directory" +pass "omarchy state set refuses a state name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a slash" "exit: $status" +pass "omarchy state set refuses a state name with a slash" + +# clear takes patterns by design ("state-name-or-pattern") and matches +# basenames through find -name, so it can never walk out of the directory. +touch "$state_dir/restart-a-required" "$state_dir/restart-b-required" "$state_dir/keep-me" +HOME="$fake_home" "$ROOT/bin/omarchy-state" clear "restart-*-required" +[[ ! -e $state_dir/restart-a-required && ! -e $state_dir/restart-b-required && -f $state_dir/keep-me ]] || + fail "omarchy state clear still clears matching patterns only" +pass "omarchy state clear still clears matching patterns only"