diff --git a/.tests/e2e-private-tier.sh b/.tests/e2e-private-tier.sh index 101b436..cac2076 100755 --- a/.tests/e2e-private-tier.sh +++ b/.tests/e2e-private-tier.sh @@ -92,12 +92,30 @@ proc wait_for {pat what} { } } -# dotup's own three: plain `read`, no TUI, so the text is the whole signal +# dotup's own three: plain `read`, no TUI, so the text is the whole signal. +# +# The FIRST password sent here is deliberately wrong. These credentials are +# asked for at the very end of a run, so a typo used to be fatal -- one wrong +# character and the whole install had to be repeated to get back to this +# prompt. Getting it wrong on purpose is the only way to prove the retry loop +# exists and that the URL and username survive the attempt. wait_for {Bootstrap URL:} "the bootstrap URL prompt" send -- "$env(BOOT_URL)\r" wait_for {Username:} "the username prompt" send -- "$env(BOOT_USER)\r" wait_for {Password:} "the password prompt" +send -- "definitely-not-the-password\r" + +# The message must name the actual fault. "endpoint refused the credentials" +# was once emitted for a 401, a 404 and an unreachable host alike. +wait_for {wrong username or password} "the wrong-password message" +# Blank keeps the URL and the username, so only the password is retyped. The +# bracketed default in the prompt is what proves they were retained. +wait_for {Bootstrap URL \[} "the retry prompt, with the URL kept" +send -- "\r" +wait_for {Username \[} "the retry username prompt, with the user kept" +send -- "\r" +wait_for {Password:} "the retry password prompt" send -- "$env(BOOT_PW)\r" # The private repo's seven, each answered only once its TUI is genuinely ready # to read. diff --git a/.tests/test.sh b/.tests/test.sh index 4404990..274f48e 100755 --- a/.tests/test.sh +++ b/.tests/test.sh @@ -191,6 +191,29 @@ is "PRIV_CFG is not the default config path" "0" \ is "the private source is locked down after clone" "1" \ "$(grep -c 'chmod -R go-rwx "\$PRIV_SRC"' $D)" +printf '\n\033[1ma wrong password is not a reinstall\033[0m\n' +# The endpoint credentials are asked for at the very END of a run, after every +# package is installed. Any non-200 used to be fatal, so one mistyped character +# meant repeating the whole install to get back to the prompt. +is "the prompt retries instead of returning" "0" \ + "$(grep -c 'endpoint refused the credentials' $D)" +is "…up to a bounded number of attempts" "1" \ + "$(grep -c '"$p_try" -gt 5' $D)" +is "…and points at the cheap way back in" "1" \ + "$(grep -c 'Re-run only this step: dotup private' $D)" +# One message for 401, 404 and an unreachable host is how a URL-shape bug spends +# an evening looking like a password problem. Each needs a different next move. +is "a 401 names the password" "1" "$(grep -c 'wrong username or password' $D)" +is "a 404 names the route" "1" "$(grep -c 'no bootstrap.env is there' $D)" +is "an unreachable host says so" "1" "$(grep -c 'could not reach that address' $D)" +# --fail is gone, so the code must be checked explicitly or an error page would +# be parsed as the credential blob. +is "the blob is used only on 200" "1" "$(grep -cF '200) break ;;' $D)" +# The subcommand existed but was absent from --help, so there was no way to +# discover the recovery path. +is "dotup private is documented" "1" \ + "$(sh $D --help 2>&1 | grep -c 'ONLY the private tier')" + printf '\n\033[1mrisk model — the invariants that matter\033[0m\n' reset is "defaults tick nothing invasive" "" "$(picked invasive)" diff --git a/dot_local/bin/executable_dotup b/dot_local/bin/executable_dotup index f8adbc1..5b74fba 100755 --- a/dot_local/bin/executable_dotup +++ b/dot_local/bin/executable_dotup @@ -781,30 +781,80 @@ cmd_private() { head_ "private tier" say " The address is in no repository. Leave it blank to stay public-only." # Read into variables: nothing reaches argv, so nothing reaches `ps`. - P_URL=''; P_USER=''; P_PW='' - printf ' Bootstrap URL: ' >&2; IFS= read -r P_URL || : - [ -n "$P_URL" ] || { say " public-only machine. Nothing was asked for."; return 0; } - # Accept the directory OR the full file URL, because both are in circulation: - # the rotation scripts print the file form and say to store THAT in Bitwarden, - # so pasting what you saved is the likely case. Without this, the request - # becomes .../bootstrap.env/bootstrap.env, the 404 trips curl --fail, and the - # error below blames the credentials for what is actually a URL shape. - P_URL=${P_URL%/} - P_URL=${P_URL%/bootstrap.env} - printf ' Username: ' >&2; IFS= read -r P_USER || : - printf ' Password: ' >&2 - stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2 + P_URL=''; P_USER=''; P_PW=''; p_in='' # shellcheck disable=SC2064 trap 'unset P_URL P_USER P_PW P_BLOB p_repo p_tok p_cred 2>/dev/null || :' EXIT INT TERM - # curl -K - reads its config, credentials included, from stdin rather than - # the command line. --fail matters too: without it a 401 body is parsed as - # if it were the blob. - P_BLOB=$(printf 'user = "%s:%s"\nsilent\nfail\n' "$P_USER" "$P_PW" \ - | curl -K - "${P_URL%/}/bootstrap.env" 2>/dev/null) || { - err "endpoint refused the credentials. The machine stays public-only." - unset P_PW; return 1; } - unset P_PW # spent. only the fetched credentials exist now. + # Ask, and keep asking. + # + # These three questions come at the very END of a run, after every package is + # installed, because a password typed at picker time would sit in memory + # through ten minutes of downloads. That ordering is right, and it is exactly + # what made one mistyped character so expensive: any non-200 used to be fatal, + # so a typo meant re-running the entire install to get back to this prompt. + # Nothing about a wrong password justifies reinstalling a compiler. + # + # URL and username persist across attempts and blank keeps them, because the + # password is the thing you get wrong, and retyping an address you already + # pasted correctly is its own source of error. + p_try=0 + while :; do + p_try=$((p_try + 1)) + if [ "$p_try" -gt 5 ]; then + err "five failed attempts — stopping rather than looping." + say " Nothing else needs redoing. Re-run only this step: dotup private" + return 1 + fi + + if [ -n "$P_URL" ]; then printf ' Bootstrap URL [%s]: ' "$P_URL" >&2 + else printf ' Bootstrap URL: ' >&2; fi + IFS= read -r p_in || : + case $p_in in + q|Q) say " public-only machine. Nothing was asked for."; return 0 ;; + '') [ -n "$P_URL" ] || { say " public-only machine. Nothing was asked for."; return 0; } ;; + *) P_URL=$p_in ;; + esac + # Accept the directory OR the full file URL, because both are in + # circulation: the rotation scripts print the file form and say to store + # THAT in Bitwarden, so pasting what you saved is the likely case. Without + # this the request becomes .../bootstrap.env/bootstrap.env, and the 404 + # would be reported below as a credential problem. + P_URL=${P_URL%/} + P_URL=${P_URL%/bootstrap.env} + + if [ -n "$P_USER" ]; then printf ' Username [%s]: ' "$P_USER" >&2 + else printf ' Username: ' >&2; fi + IFS= read -r p_in || : + [ -z "$p_in" ] || P_USER=$p_in + + printf ' Password: ' >&2 + stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2 + + # curl -K - reads its config, credentials included, from stdin rather than + # the command line, so nothing reaches `ps`. The status is captured + # alongside the body instead of relying on --fail, because "it failed" is + # not a useful thing to say here: 401, 404 and an unreachable host each + # need a different next move, and the old single message blamed the + # credentials for all three. The body is used only when the code is 200, + # so an error page is still never parsed as a blob. + p_resp=$(printf 'user = "%s:%s"\nsilent\nwrite-out = "\\n%%{http_code}"\n' "$P_USER" "$P_PW" \ + | curl -K - --connect-timeout 15 -m 120 "$P_URL/bootstrap.env" 2>/dev/null) || : + p_code=$(printf '%s\n' "$p_resp" | tail -n1) + P_BLOB=$(printf '%s\n' "$p_resp" | sed '$d') + p_resp='' + P_PW='' + + case $p_code in + 200) break ;; + 401) err "wrong username or password." ;; + 404) err "reached the host, but no bootstrap.env is there — check the route in the URL." ;; + 000) err "could not reach that address (DNS, TLS, or the host is down)." ;; + *) err "the endpoint answered HTTP $p_code." ;; + esac + P_BLOB='' + say " Try again, or type q at the URL prompt to stay public-only." + done + unset P_PW p_in p_code p_try 2>/dev/null || : # Contract with the endpoint (phase 4 writes the file this parses): # two KEY=VALUE lines, no quoting, no shell @@ -1016,6 +1066,10 @@ usage() { --print, -n resolve everything and print the commands, install nothing --yes, -y skip the confirmation after the picker + private ONLY the private tier: prompt for the endpoint and fetch. + Everything already installed is left alone, so this is the + way back in after a mistyped password -- no reinstall. + plumbing, called by the fzf bindings: render toggle expand expand-all preset explain plan preflight EOF