diff --git a/studio/setup.sh b/studio/setup.sh index 6fed3ddc4d..e25bcb7a0b 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -978,20 +978,18 @@ _studio_owned_adoptable() { [ -f "$1/UNSLOTH_WHISPER_PREBUILT_INFO.json" ] && return 0 return 1 } -# Search (+x), not read (+r), is what the marker probes need: inside a directory -# we cannot search every probe reports absent, so our own install reads as someone else's. +# Marker probes need search (+x), not read (+r): in an unsearchable dir every probe reports absent, so our install looks foreign. _studio_dir_unsearchable() { [ -d "$1" ] || return 1 - ( cd "$1" ) 2>/dev/null && return 1 + ( cd -- "$1" ) 2>/dev/null && return 1 return 0 } -# Also needs +r, for callers that list or replace the tree: mode 111 is searchable -# but still fails install_llama_prebuilt.py. +# Also needs +r for callers that list or replace the tree: mode 111 is searchable but still fails install_llama_prebuilt.py. _studio_dir_unreadable() { [ -d "$1" ] || return 1 _studio_dir_unsearchable "$1" && return 0 - ls -A "$1" >/dev/null 2>&1 && return 1 + ls -A -- "$1" >/dev/null 2>&1 && return 1 return 0 } @@ -1018,6 +1016,41 @@ _path_access_denied() { setup_fail 1 "Permission denied reading the existing $_pad_label at $_pad_dir. Delete or rename that folder (Unsloth reinstalls it) or restore access, then re-run setup. Reinstalling the app does not reset it." } +# POSIX follows a final symlink when the path ends in /, so "link/" is never -L. Strip it, but never past the root. +_studio_rstrip_slash() { + _srs_path="$1" + while [ "$_srs_path" != "/" ] && [ "${_srs_path%/}" != "$_srs_path" ]; do + _srs_path="${_srs_path%/}" + done + printf '%s' "$_srs_path" +} + +# An unsearchable ancestor makes a real path read as missing. Walk up to the deepest +# ancestor we can stat and report it as the blocker; stay quiet if the path is just absent. +_report_denied_ancestor() { + _rda_probe="$(_studio_rstrip_slash "$1")" + _rda_hops=0 + while [ ! -e "$_rda_probe" ] && [ "$_rda_probe" != "/" ] && [ "$_rda_probe" != "." ]; do + # An unfollowable symlink is the deepest name we have, so walk its target: + # the denied ancestor lives there. The hop cap breaks symlink cycles. + if [ -L "$_rda_probe" ] && [ "$_rda_hops" -lt 40 ]; then + _rda_hops=$((_rda_hops + 1)) + _rda_target="$(readlink -- "$_rda_probe")" || break + case "$_rda_target" in + /*) _rda_probe="$_rda_target" ;; + *) _rda_probe="$(dirname -- "$_rda_probe")/$_rda_target" ;; + esac + _rda_probe="$(_studio_rstrip_slash "$_rda_probe")" + continue + fi + # -- keeps a leading-dash path an operand, not a dirname option. + _rda_probe="$(dirname -- "$_rda_probe")" + done + if _studio_dir_unsearchable "$_rda_probe"; then + _path_access_denied "$_rda_probe" "$2" owner-unverified + fi +} + _assert_studio_owned_or_absent() { _aso_dir="$1" _aso_label="$2" @@ -1948,10 +1981,17 @@ _has_local_llama_server() { _LOCAL_LLAMA_CPP_LINKED=false if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then if [ ! -d "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" ]; then + # A build under an unsearchable ancestor cannot be stat'd, so report permissions + # rather than sending the user to fix a path that is already correct. + _report_denied_ancestor "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" "UNSLOTH_LOCAL_LLAMA_CPP_DIR" step "llama.cpp" "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $UNSLOTH_LOCAL_LLAMA_CPP_DIR" "$C_ERR" setup_fail 1 "UNSLOTH_LOCAL_LLAMA_CPP_DIR does not exist: $UNSLOTH_LOCAL_LLAMA_CPP_DIR" fi - _RESOLVED_LOCAL="$(CDPATH= cd -P -- "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" && pwd -P)" + # In an if condition so a denied dir reports instead of tripping errexit. + if ! _RESOLVED_LOCAL="$(CDPATH= cd -P -- "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" 2>/dev/null && pwd -P)"; then + # owner-unverified: this is the user's own tree, never advise deleting it. + _path_access_denied "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" "UNSLOTH_LOCAL_LLAMA_CPP_DIR" owner-unverified + fi # Canonicalize the install path the same way before comparing: _RESOLVED_LOCAL # is fully resolved, but LLAMA_CPP_DIR is textual ($UNSLOTH_HOME/llama.cpp). If # $HOME (or UNSLOTH_HOME) contains a symlink, the two never match even when the @@ -1961,7 +2001,13 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then _CANON_LLAMA_CPP_DIR="$LLAMA_CPP_DIR" _LLAMA_CPP_PARENT="$(dirname "$LLAMA_CPP_DIR")" if [ -d "$_LLAMA_CPP_PARENT" ]; then - _CANON_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_LLAMA_CPP_PARENT" && pwd -P)/$(basename "$LLAMA_CPP_DIR")" + # Nothing can be written under a parent we cannot search, so report here + # rather than let the link below abort raw a few lines later. + if _canon_parent="$(CDPATH= cd -P -- "$_LLAMA_CPP_PARENT" 2>/dev/null && pwd -P)"; then + _CANON_LLAMA_CPP_DIR="$_canon_parent/$(basename "$LLAMA_CPP_DIR")" + else + _path_access_denied "$_LLAMA_CPP_PARENT" "Unsloth install directory" owner-unverified + fi fi if [ "$_RESOLVED_LOCAL" = "$_CANON_LLAMA_CPP_DIR" ]; then # Points at the canonical install location itself: never delete-then-link diff --git a/tests/sh/test_setup_sh_denied_install_tree.sh b/tests/sh/test_setup_sh_denied_install_tree.sh index 894ec6a290..a4f196f0b2 100755 --- a/tests/sh/test_setup_sh_denied_install_tree.sh +++ b/tests/sh/test_setup_sh_denied_install_tree.sh @@ -26,10 +26,9 @@ echo "=== setup.sh: the guards exist and probe the right permission ===" assert_contains "defines the unsearchable-directory probe" \ "$SETUP_SH" "_studio_dir_unsearchable() {" -# cd needs +x, exactly what the marker probes need; ls needs +r, which is neither -# sufficient nor necessary. Pin the cd form. +# cd needs +x, what the marker probes need; ls needs +r, neither sufficient nor necessary. Pin the cd form. assert_contains "the probe tests search (cd), not read (ls)" \ - "$SETUP_SH" '( cd "$1" ) 2>/dev/null && return 1' + "$SETUP_SH" '( cd -- "$1" ) 2>/dev/null && return 1' assert_contains "defines the denial reporter" \ "$SETUP_SH" "_path_access_denied() {" assert_contains "the ownership guard checks readability before blaming ownership" \ @@ -111,6 +110,25 @@ else ok "no unguarded rm -rf of the install dir remains" fi +# A bare $(cd ...) assignment aborts under errexit before setup_fail can report, leaving +# an exit code with no [TAURI:ERROR]. Both must sit in an if condition, which errexit exempts. +if grep -qE '^\s*_RESOLVED_LOCAL="\$\(CDPATH= cd' "$SETUP_SH"; then + bad "a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR reports instead of tripping errexit" +else + ok "a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR reports instead of tripping errexit" +fi +if grep -qE '^\s*_CANON_LLAMA_CPP_DIR="\$\(CDPATH= cd' "$SETUP_SH"; then + bad "an unsearchable install parent is reported instead of aborting" +else + ok "an unsearchable install parent is reported instead of aborting" +fi +# Carrying on past a denied parent only moves the abort to the ln a few lines down. +assert_contains "a denied install parent stops rather than continuing" \ + "$SETUP_SH" '_path_access_denied "$_LLAMA_CPP_PARENT" "Unsloth install directory" owner-unverified' +# An unsearchable ancestor makes a real path unstattable, so [ ! -d ] would call it missing. +assert_contains "a denied ancestor is reported before the missing-path guard" \ + "$SETUP_SH" '_report_denied_ancestor "$UNSLOTH_LOCAL_LLAMA_CPP_DIR" "UNSLOTH_LOCAL_LLAMA_CPP_DIR"' + echo "" echo "=== behaviour against a genuinely unsearchable tree ===" @@ -123,7 +141,8 @@ import sys, pathlib src = pathlib.Path(sys.argv[1]).read_text() out = [] for name in ("_studio_owned_adoptable", "_studio_dir_unsearchable", - "_studio_dir_unreadable", + "_studio_dir_unreadable", "_studio_rstrip_slash", + "_report_denied_ancestor", "_path_access_denied", "_assert_studio_owned_or_absent"): i = src.index(name + "() {") out.append(src[i:src.index("\n}\n", i) + 3]) @@ -225,6 +244,113 @@ else fi chmod 755 "$NOLIST" +# A real build under an unsearchable ancestor must report permissions, not "missing". +ANC="$WORK/anc"; mkdir -p "$ANC/denied/llama.cpp" +chmod 000 "$ANC/denied" +if [ -d "$ANC/denied/llama.cpp" ]; then + echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)" +else + ok "the ancestor is really unsearchable (negative control)" + out=$(bash -c '. "$1" + C_ERR= C_WARN= C_DIM= C_OK= C_RST= + step() { printf "STEP|%s|%s\n" "$1" "$2"; }; substep() { :; } + setup_fail() { printf "FAIL|%s\n" "$2"; exit "$1"; } + _report_denied_ancestor "$2" "UNSLOTH_LOCAL_LLAMA_CPP_DIR" + echo "NOT_REPORTED"' _ "$WORK/helpers.sh" "$ANC/denied/llama.cpp" 2>&1) + case "$out" in + *"cannot be read: permission denied"*) ok "a build under a denied ancestor reports permissions" ;; + *) bad "a build under a denied ancestor reports permissions (got: $out)" ;; + esac + case "$out" in + *"$ANC/denied"*) ok "the message names the denied ancestor, not the leaf" ;; + *) bad "the message names the denied ancestor, not the leaf (got: $out)" ;; + esac +fi +chmod 755 "$ANC/denied" +# A genuinely missing path must still be reported as missing, not as denied. +out=$(bash -c '. "$1" + C_ERR= C_WARN= C_DIM= C_OK= C_RST= + step() { :; }; substep() { :; } + setup_fail() { printf "FAIL|%s\n" "$2"; exit "$1"; } + _report_denied_ancestor "$2" "UNSLOTH_LOCAL_LLAMA_CPP_DIR" + echo "NOT_REPORTED"' _ "$WORK/helpers.sh" "$WORK/definitely-absent" 2>&1) +case "$out" in + *NOT_REPORTED*) ok "a genuinely missing path is not reported as denied" ;; + *) bad "a genuinely missing path is not reported as denied (got: $out)" ;; +esac + +# Run the reporter over $2 from directory $1, with errexit on like the real script. +rda() { + ( cd "$1" && bash -c 'set -e + . "$1" + C_ERR= C_WARN= C_DIM= C_OK= C_RST= + step() { printf "STEP|%s|%s\n" "$1" "$2"; }; substep() { :; } + setup_fail() { printf "FAIL|%s\n" "$2"; exit "$1"; } + _report_denied_ancestor "$2" "UNSLOTH_LOCAL_LLAMA_CPP_DIR" + echo "NOT_REPORTED"' _ "$WORK/helpers.sh" "$2" 2>&1 ) +} + +# A symlink under a denied ancestor is unstattable all the way down, so a lexical walk alone would call the build missing. +SYM="$WORK/sym"; mkdir -p "$SYM/shared/denied/build/llama.cpp" "$SYM/tmp" +ln -s "$SYM/shared/denied/build" "$SYM/tmp/local" +chmod 000 "$SYM/shared/denied" +if [ -d "$SYM/tmp/local/llama.cpp" ]; then + echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)" +else + ok "the symlink target is really unreachable (negative control)" + out=$(rda "$WORK" "$SYM/tmp/local/llama.cpp") + case "$out" in + *"$SYM/shared/denied"*) ok "a symlinked build names the denied target ancestor" ;; + *) bad "a symlinked build names the denied target ancestor (got: $out)" ;; + esac +fi +chmod 755 "$SYM/shared/denied" + +# A trailing slash follows the link, so "link/" is not -L: it must still report. +chmod 000 "$SYM/shared/denied" +if [ -d "$SYM/tmp/local/llama.cpp" ]; then + echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)" +else + case "$(rda "$WORK" "$SYM/tmp/local/")" in + *"$SYM/shared/denied"*) ok "a trailing slash still finds the denied target" ;; + *) bad "a trailing slash still finds the denied target" ;; + esac +fi +chmod 755 "$SYM/shared/denied" + +# Stripping must stop at the root instead of emptying the path. +case "$(bash -c '. "$1"; _studio_rstrip_slash "/"; printf "|"; _studio_rstrip_slash "//"' _ "$WORK/helpers.sh")" in + "/|/") ok "stripping trailing slashes never consumes the root" ;; + *) bad "stripping trailing slashes never consumes the root" ;; +esac + +# A dangling symlink is genuinely missing, so it must not be reported as denied. +ln -s "$SYM/gone" "$SYM/tmp/dangle" +case "$(rda "$WORK" "$SYM/tmp/dangle/llama.cpp")" in + *NOT_REPORTED*) ok "a dangling symlink is not reported as denied" ;; + *) bad "a dangling symlink is not reported as denied" ;; +esac + +# A symlink cycle must terminate on the hop cap instead of looping forever. +ln -s "$SYM/tmp/a" "$SYM/tmp/b"; ln -s "$SYM/tmp/b" "$SYM/tmp/a" +case "$(rda "$WORK" "$SYM/tmp/a/llama.cpp")" in + *NOT_REPORTED*) ok "a symlink cycle terminates without reporting" ;; + *) bad "a symlink cycle terminates without reporting" ;; +esac + +# A leading-dash path must reach the reporter, not be eaten as a dirname option and abort on errexit. +DASH="$WORK/dash"; mkdir -p "$DASH" +( cd "$DASH" && mkdir -p -- "-denied/llama.cpp" && chmod 000 -- "-denied" ) +if [ -d "$DASH/-denied/llama.cpp" ]; then + echo " SKIP: this host cannot make an ancestor unsearchable (running as root?)" +else + case "$(rda "$DASH" "-denied/llama.cpp")" in + *"cannot be read: permission denied"*) ok "a leading-dash path reports instead of aborting" ;; + *) bad "a leading-dash path reports instead of aborting" ;; + esac +fi +chmod 755 -- "$DASH/-denied" + echo "" echo "=== Results ===" echo " PASS: $PASS"