diff --git a/bin/omarchy-mise-install b/bin/omarchy-mise-install index e6dfc96c..356c3324 100755 --- a/bin/omarchy-mise-install +++ b/bin/omarchy-mise-install @@ -12,8 +12,38 @@ package=$1 command=${2:-$1} bin=${3:-$command} +# The command name becomes a file name under ~/.local/bin, so a slash in it +# writes the wrapper somewhere else and the rm below deletes somewhere else. A +# leading dot hides it or walks up, and a leading dash makes a name that reads +# as an option to whatever picks it up. Checked before anything is removed or +# written, and kept to those shapes so package names like npm:playwright still +# stand in for the command name. +case "$command" in + */* | .* | -* | *[[:cntrl:]]*) + echo "omarchy-mise-install: '$command' is not usable as a command name" >&2 + exit 1 + ;; +esac + mkdir -p "$HOME/.local/bin" +# The heredoc below is unquoted, so whatever these hold is written into the +# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so +# a package name carrying shell characters stays one argument instead of running. +printf -v package_arg '%q' "$package" +printf -v bin_arg '%q' "$bin" + +# Keep values that are inert inside double quotes in the form existing migrations +# recognize, including mise backend options that %q would unnecessarily escape. +case "$package" in + *'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;; + *) package_arg="\"$package\"" ;; +esac +case "$bin" in + *'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;; + *) bin_arg="\"$bin\"" ;; +esac + # These tools install and upgrade on first run, so mise's release cooldown would # hold a new version back for days after it ships. Exported rather than set on # the install line alone, so resolving the version to execute agrees with the @@ -22,8 +52,8 @@ rm -f "$HOME/.local/bin/$command" cat >"$HOME/.local/bin/$command" <"$stub_bin/mise" <<'SH' +#!/bin/bash + +printf 'mise' >>"$OMARCHY_MISE_TEST_LOG" +for arg in "$@"; do + printf '\t%s' "$arg" >>"$OMARCHY_MISE_TEST_LOG" +done +printf '\n' >>"$OMARCHY_MISE_TEST_LOG" +SH +chmod +x "$stub_bin/mise" + +install_wrapper() { + HOME="$home" "$ROOT/bin/omarchy-mise-install" "$@" +} + +# The ordinary case still works, and every call site in install/user/mise.sh +# passes names of this shape. +install_wrapper npm:playwright playwright >/dev/null +[[ -x $home/.local/bin/playwright ]] || + fail "a normal install writes an executable wrapper" + +log="$tmpdir/normal.log" +: >"$log" +OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/playwright" >/dev/null +grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:playwright' "$log" || + fail "the wrapper asks mise for the package it was given" "$(cat "$log")" + +pass "a normal install writes a wrapper that names its package" + +# A package name is data. Quoted with %q it reaches mise as one argument +# instead of being read as shell source when the wrapper runs. +install_wrapper 'npm:pkg$(touch '"$tmpdir"'/PWNED)end' hostile >/dev/null + +log="$tmpdir/hostile.log" +: >"$log" +OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/hostile" >/dev/null + +[[ -e $tmpdir/PWNED ]] && + fail "a package name with shell characters does not run when the wrapper does" \ + "wrapper: $(cat "$home/.local/bin/hostile")" + +grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:pkg$(touch '"$tmpdir"'/PWNED)end' "$log" || + fail "the package reaches mise whole" "$(cat "$log")" + +pass "a package name with shell characters reaches mise as one argument" + +# The command name is a file name under ~/.local/bin. These shapes escape it, +# hide it, make something that reads as an option, or carry characters that have +# no business in a file name. Labelled so a newline in the value does not end up +# inside the test output. +refused=( + "a slash" "../escaped" + "a leading dot" ".hidden" + "a leading dash" "-dash" + "a newline" $'with\nnewline' + "a tab" $'with\ttab' +) + +for (( i = 0; i < ${#refused[@]}; i += 2 )); do + label=${refused[i]} + name=${refused[i + 1]} + + if install_wrapper somepkg "$name" >/dev/null 2>"$tmpdir/err"; then + fail "a command name with $label is refused" + fi + grep -Fq 'is not usable as a command name' "$tmpdir/err" || + fail "the refusal says why for a command name with $label" "$(cat "$tmpdir/err")" +done + +pass "command names that are not plain file names are refused" + +# The refusal has to land before the rm, which would otherwise delete the +# escaped path on its way to failing. +victim="$tmpdir/victim" +printf 'keep me\n' >"$victim" +if install_wrapper somepkg "../../../..$victim" >/dev/null 2>&1; then + fail "an escaping command name is refused" +fi +[[ -f $victim ]] || + fail "an escaping command name removes nothing outside ~/.local/bin" + +pass "an escaping command name removes nothing outside ~/.local/bin"