diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index f77d5561..9ce5249f 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -2,13 +2,6 @@ mkdir -p "$HOME/Work" mkdir -p "$HOME/Work/tries" -cat >"$HOME/Work/.mise.toml" <<'EOF' -[env] -_.path = "{{ cwd }}/bin" -EOF - -mise trust ~/Work/.mise.toml - # Offline installs unpack the Node tarball bundled by the ISO: from # /opt/packages in the ISO chroot, or from the copy staged in provisioning state when # omarchy-provision-owner finalizes the user at first boot. diff --git a/manual/19-shell-tools.md b/manual/19-shell-tools.md index 69965fdd..8252d795 100644 --- a/manual/19-shell-tools.md +++ b/manual/19-shell-tools.md @@ -57,3 +57,5 @@ The full manual can be found via `man yt-dlp`. ## try [try](https://github.com/tobi/try) makes it easy to manage programming experiments with date-stamped directories. All experiments live in `~/Work/tries` and you can access them via `try`. + +Omarchy does not add a project's `bin/` directory to `PATH` automatically. Run trusted project-local tools with an explicit relative path, such as `bin/rails` or `./bin/dev`. diff --git a/migrations/1789095456.sh b/migrations/1789095456.sh new file mode 100644 index 00000000..381557fc --- /dev/null +++ b/migrations/1789095456.sh @@ -0,0 +1,99 @@ +echo "Remove automatic project bin directories from PATH" + +work_dir="$HOME/Work" +mise_config="$work_dir/.mise.toml" +# install/user/mise-work.sh as shipped in Omarchy 4.0.3. +stock_sha="bd04f191d63bbde86920f44f76f0989fad980afc84e268e8474c201ec7149245" +cwd_bin='\{\{[[:space:]]*cwd[[:space:]]*\}\}/bin' +unsafe_path="^[[:space:]]*_[.]path[[:space:]]*=[[:space:]]*(\"$cwd_bin\"|'$cwd_bin')[[:space:]]*(#.*)?$" +env_section='^[[:space:]]*\[[[:space:]]*env[[:space:]]*\][[:space:]]*(#.*)?$' +any_section='^[[:space:]]*\[\[?.*\]\]?[[:space:]]*(#.*)?$' + +remove_empty_work_dir=false +if [[ ! -e $work_dir ]]; then + mkdir -p "$work_dir" + remove_empty_work_dir=true +fi + +was_ignored=false +if [[ -d $work_dir ]]; then + mise_state_dir=${MISE_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mise} + ignored_configs_dir="$mise_state_dir/ignored-configs" + work_target=$(readlink -m "$work_dir") + config_path_target="$work_target/.mise.toml" + config_target=$(readlink -m "$mise_config") + + if [[ -d $ignored_configs_dir ]]; then + for ignored_entry in "$ignored_configs_dir"/*; do + [[ -L $ignored_entry ]] || continue + ignored_target=$(readlink "$ignored_entry") + if [[ $ignored_target == $work_target || $ignored_target == $config_path_target || $ignored_target == $config_target ]]; then + was_ignored=true + break + fi + done + fi + + if [[ $was_ignored == "false" ]]; then + # Normal Mise trust is recorded against the config-root directory, while + # paranoid trust is recorded against the file and its contents. Stage an + # empty, inert config when the legacy file is gone so either trust mode can + # resolve and revoke the original grant. + remove_empty_mise_config=false + if [[ ! -e $mise_config && ! -L $mise_config ]]; then + if (set -o noclobber; : >"$mise_config") 2>/dev/null; then + remove_empty_mise_config=true + fi + fi + + untrust_target="$work_dir" + if [[ -f $mise_config ]]; then + untrust_target="$mise_config" + fi + + if mise trust --untrust "$untrust_target"; then + : + else + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + exit 1 + fi + + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ ! -L $mise_config && $(sha256sum "$mise_config" | cut -d ' ' -f 1) == $stock_sha ]]; then + rm -f -- "$mise_config" + else + unsafe_env_paths=$(sed -n -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%p; }" "$mise_config") + if [[ -n $unsafe_env_paths ]]; then + backup=$(mktemp "$mise_config.bak.XXXXXX") + cp -p -- "$mise_config" "$backup" + sed --follow-symlinks -i -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%d; }" "$mise_config" + + printf '\n%s\n' \ + "Automatic project bin directories were removed from your Mise PATH." \ + "Your other Mise settings were preserved." + printf '\nBackup saved to:\n %s\n' "$backup" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ $was_ignored == "true" ]]; then + printf '\n%s\n' "This custom config remains ignored by Mise." + else + printf '\n%s\n %s\n' \ + "Mise trust for this custom config was revoked. Review it before trusting it again:" \ + "mise trust $mise_config" + fi +fi + +if [[ $remove_empty_work_dir == "true" ]]; then + rmdir "$work_dir" 2>/dev/null || true +fi diff --git a/test/shell.d/mise-work-path-test.sh b/test/shell.d/mise-work-path-test.sh new file mode 100644 index 00000000..9c50b91b --- /dev/null +++ b/test/shell.d/mise-work-path-test.sh @@ -0,0 +1,270 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command mise + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +migration="$ROOT/migrations/1789095456.sh" + +run_migration() { + local test_home="$1" + + env -i \ + HOME="$test_home" \ + XDG_CACHE_HOME="$test_home/.cache" \ + XDG_CONFIG_HOME="$test_home/.config" \ + XDG_DATA_HOME="$test_home/.local/share" \ + XDG_STATE_HOME="$test_home/.local/state" \ + MISE_PARANOID="${OMARCHY_TEST_MISE_PARANOID:-false}" \ + PATH=/usr/bin \ + bash -euo pipefail "$migration" +} + +run_mise() { + local test_home="$1" + shift + + env -i \ + HOME="$test_home" \ + XDG_CACHE_HOME="$test_home/.cache" \ + XDG_CONFIG_HOME="$test_home/.config" \ + XDG_DATA_HOME="$test_home/.local/share" \ + XDG_STATE_HOME="$test_home/.local/state" \ + MISE_PARANOID="${OMARCHY_TEST_MISE_PARANOID:-false}" \ + PATH=/usr/bin \ + mise "$@" +} + +mise_environment() { + local test_home="$1" + local project="$2" + + ( + cd "$project" + run_mise "$test_home" env -s bash + ) +} + +mise_path_active() { + local test_home="$1" + local project="$2" + local output + + if ! output=$(mise_environment "$test_home" "$project" 2>/dev/null); then + return 1 + fi + + grep -F "$project/bin" <<<"$output" >/dev/null +} + +assert_unsafe_variant_removed() { + local variant="$1" + local assignment="$2" + local variant_home="$test_dir/$variant-home" + local variant_config="$variant_home/Work/.mise.toml" + local variant_project="$variant_home/Work/tries/untrusted-repository" + + mkdir -p "$variant_project/bin" + printf '[env]\n%s\n' "$assignment" >"$variant_config" + run_mise "$variant_home" trust "$variant_config" >/dev/null + + mise_path_active "$variant_home" "$variant_project" || fail "$variant legacy config prepends the repository bin directory" + + run_migration "$variant_home" >/dev/null + if mise_path_active "$variant_home" "$variant_project"; then + fail "$variant repository bin directory remains in PATH after migration" + fi +} + +install_home="$test_dir/install-home" +install_log="$test_dir/install-mise.log" +mkdir -p "$install_home" "$test_dir/bin" +cat >"$test_dir/bin/mise" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$MISE_TEST_LOG" +SH +chmod +x "$test_dir/bin/mise" + +env \ + HOME="$install_home" \ + MISE_TEST_LOG="$install_log" \ + OMARCHY_SETUP_CONTEXT=runtime \ + PATH="$test_dir/bin:/usr/bin" \ + bash -euo pipefail -c 'source "$1"' bash "$ROOT/install/user/mise-work.sh" + +[[ -d $install_home/Work/tries ]] || fail "installer creates the work and tries directories" +[[ ! -e $install_home/Work/.mise.toml ]] || fail "installer does not create a trusted Work Mise config" +[[ $(<"$install_log") == "use -g node@latest" ]] || fail "installer only invokes Mise for the global Node setup" +pass "new installs do not add project bin directories to PATH" + +stock_home="$test_dir/stock-home" +stock_config="$stock_home/Work/.mise.toml" +stock_project="$stock_home/Work/tries/untrusted-repository" +mkdir -p "$stock_project/bin" +cat >"$stock_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +TOML +run_mise "$stock_home" trust "$stock_config" >/dev/null + +mise_path_active "$stock_home" "$stock_project" || fail "legacy config prepends the repository bin directory" + +run_migration "$stock_home" >/dev/null +[[ ! -e $stock_config ]] || fail "migration removes the stock Work Mise config" +cat >"$stock_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +TOML +if mise_path_active "$stock_home" "$stock_project"; then + fail "recreated Work config remains trusted after migration" +fi +run_migration "$stock_home" >/dev/null +[[ ! -e $stock_config ]] || fail "stock migration is idempotent" +pass "migration removes the repository bin directory and revokes the Work trust root" + +assert_unsafe_variant_removed inline-comment '_.path = "{{ cwd }}/bin" # Omarchy default' +assert_unsafe_variant_removed single-quoted "_.path = '{{ cwd }}/bin'" +pass "migration removes annotated and single-quoted project bin paths" + +custom_home="$test_dir/custom-home" +custom_config="$custom_home/Work/.mise.toml" +mkdir -p "$(dirname "$custom_config")" +cat >"$custom_config" <<'TOML' +[env] +KEEP = "yes" + _.path = "{{ cwd }}/bin" +# _.path = "{{ cwd }}/bin" + +[tools] +ruby = "latest" + +[other] +_.path = "{{ cwd }}/bin" +TOML +cp "$custom_config" "$test_dir/custom-original" +cat >"$test_dir/custom-expected" <<'TOML' +[env] +KEEP = "yes" +# _.path = "{{ cwd }}/bin" + +[tools] +ruby = "latest" + +[other] +_.path = "{{ cwd }}/bin" +TOML +chmod 600 "$custom_config" +run_mise "$custom_home" trust "$custom_config" >/dev/null + +custom_output=$(run_migration "$custom_home") +cmp -s "$test_dir/custom-expected" "$custom_config" || fail "migration preserves unrelated custom Mise settings" +[[ $(stat -c %a "$custom_config") == "600" ]] || fail "migration preserves custom config permissions" +grep -F "mise trust $custom_config" <<<"$custom_output" >/dev/null || fail "migration explains how to review and re-trust a custom config" +custom_backups=("$custom_config".bak.*) +[[ -f ${custom_backups[0]} ]] || fail "migration backs up a customized Mise config" +(( ${#custom_backups[@]} == 1 )) || fail "migration creates one custom config backup" +cmp -s "$test_dir/custom-original" "${custom_backups[0]}" || fail "custom config backup preserves the original" + +run_migration "$custom_home" >/dev/null +custom_backups=("$custom_config".bak.*) +(( ${#custom_backups[@]} == 1 )) || fail "custom migration does not create another backup on rerun" +cmp -s "$test_dir/custom-expected" "$custom_config" || fail "custom migration is idempotent" +pass "custom Mise settings, permissions, and original backup survive the repair" + +unrelated_home="$test_dir/unrelated-home" +unrelated_config="$unrelated_home/Work/.mise.toml" +unrelated_project="$unrelated_home/Work/tries/untrusted-repository" +mkdir -p "$unrelated_project/bin" +printf '[env]\nKEEP = "yes"\n' >"$unrelated_config" +cp "$unrelated_config" "$test_dir/unrelated-original" +run_mise "$unrelated_home" trust "$unrelated_config" >/dev/null +run_migration "$unrelated_home" >/dev/null +cmp -s "$test_dir/unrelated-original" "$unrelated_config" || fail "unrelated Mise config remains unchanged" +unrelated_backups=("$unrelated_config".bak.*) +[[ ! -e ${unrelated_backups[0]} ]] || fail "unchanged Mise config is not backed up" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$unrelated_config" +if mise_path_active "$unrelated_home" "$unrelated_project"; then + fail "safe Work config retains its old trust grant" +fi + +absent_home="$test_dir/absent-home" +absent_config="$absent_home/Work/.mise.toml" +absent_project="$absent_home/Work/tries/untrusted-repository" +mkdir -p "$(dirname "$absent_config")" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$absent_config" +run_mise "$absent_home" trust "$absent_config" >/dev/null +rm "$absent_config" +rmdir "$absent_home/Work" +run_migration "$absent_home" >/dev/null +[[ ! -e $absent_home/Work ]] || fail "migration does not retain a temporary Work directory" +mkdir -p "$absent_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$absent_config" +if mise_path_active "$absent_home" "$absent_project"; then + fail "deleted Work directory retains its stale trust grant" +fi +pass "migration leaves unrelated configs alone and revokes dangling Work trust" + +paranoid_home="$test_dir/paranoid-home" +paranoid_work="$paranoid_home/Work" +paranoid_config="$paranoid_work/.mise.toml" +paranoid_project="$paranoid_work/tries/untrusted-repository" +mkdir -p "$paranoid_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$paranoid_config" +OMARCHY_TEST_MISE_PARANOID=true run_mise "$paranoid_home" trust "$paranoid_config" >/dev/null +OMARCHY_TEST_MISE_PARANOID=true mise_path_active "$paranoid_home" "$paranoid_project" || fail "paranoid legacy config prepends the repository bin directory" +rm -r "$paranoid_work" + +OMARCHY_TEST_MISE_PARANOID=true run_migration "$paranoid_home" >/dev/null +[[ ! -e $paranoid_work ]] || fail "paranoid migration removes its temporary Work directory" +mkdir -p "$paranoid_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$paranoid_config" +if OMARCHY_TEST_MISE_PARANOID=true mise_path_active "$paranoid_home" "$paranoid_project"; then + fail "paranoid migration retains content-bound trust for the deleted legacy config" +fi +pass "migration revokes stale content-bound trust in Mise paranoid mode" + +ignored_home="$test_dir/ignored-home" +ignored_config="$ignored_home/Work/.mise.toml" +ignored_project="$ignored_home/Work/tries/untrusted-repository" +mkdir -p "$ignored_project/bin" +cat >"$ignored_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +KEEP = "yes" +TOML +run_mise "$ignored_home" trust "$ignored_config" >/dev/null +run_mise "$ignored_home" trust --ignore "$ignored_config" >/dev/null + +ignored_output=$(run_migration "$ignored_home") +grep -F '{{ cwd }}/bin' "$ignored_config" >/dev/null && fail "ignored config retains the unsafe path" +grep -Fx 'KEEP = "yes"' "$ignored_config" >/dev/null || fail "ignored config keeps unrelated settings" +ignored_entries=("$ignored_home/.local/state/mise/ignored-configs/"*) +[[ -L ${ignored_entries[0]} ]] || fail "migration preserves the explicit Mise ignore marker" +(( ${#ignored_entries[@]} == 1 )) || fail "migration preserves exactly one Mise ignore marker" +ignored_target=$(readlink "${ignored_entries[0]}") +[[ $ignored_target == $ignored_home/Work || $ignored_target == $ignored_config ]] || fail "preserved Mise ignore marker still targets the Work config" +grep -F "remains ignored by Mise" <<<"$ignored_output" >/dev/null || fail "migration reports that the custom config remains ignored" +if mise_path_active "$ignored_home" "$ignored_project"; then + fail "ignored config becomes active after migration" +fi +pass "migration preserves an explicit decision to ignore the Work config" + +symlink_home="$test_dir/symlink-home" +symlink_config="$symlink_home/Work/.mise.toml" +symlink_target="$test_dir/dotfiles-mise.toml" +mkdir -p "$(dirname "$symlink_config")" +cat >"$symlink_target" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +KEEP = "yes" +TOML +ln -s "$symlink_target" "$symlink_config" + +run_migration "$symlink_home" >/dev/null +[[ -L $symlink_config ]] || fail "migration preserves a dotfile symlink" +grep -F '{{ cwd }}/bin' "$symlink_target" >/dev/null && fail "symlink target retains the unsafe path" +grep -Fx 'KEEP = "yes"' "$symlink_target" >/dev/null || fail "symlink target keeps unrelated settings" +pass "custom dotfile symlinks survive the repair"