#!/bin/bash # Phase 5 end-to-end, inside a clean container. Public tier from the live # anonymous URL, then the private tier through the real bootstrap endpoint. # # Credentials arrive in the environment (BOOT_URL/BOOT_USER/BOOT_PW) and are # never echoed. Package set is scoped to the private path and its dependency: # the full manifest install is covered by the fake-package-manager suite, and # what has never run end to end is bootstrap -> bws -> private repo -> secrets. set -u export DEBIAN_FRONTEND=noninteractive fail() { echo "FAIL: $*"; exit 1; } apt-get update -qq && apt-get install -y -qq git curl zsh ca-certificates expect >/dev/null 2>&1 # ---- 1. public tier, anonymously, exactly as a stranger would --------------- sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply \ "${E2E_PUBLIC_URL:?}" >/dev/null 2>&1 \ || fail "public tier init" # Assert the ABSOLUTE path first, before touching PATH. # # A real operator has just run the installer in whatever shell they were already # in, and on a fresh box that is bash. ~/.local/bin is added to PATH only by the # .zshrc this tier ships -- for a zsh that dotup has not installed yet. So the # first invocation is necessarily `~/.local/bin/dotup`, which is exactly what # the README documents, and bare `dotup` is `command not found` on every new # machine. # # This used to read `export PATH=...` and THEN `command -v dotup`, which is a # vacuous assertion: it proved the export worked, not that the installer put # anything anywhere. It passed happily while a real bash user hit # `dotup: command not found`. [ -x "$HOME/.local/bin/dotup" ] \ || fail "public apply left no executable at ~/.local/bin/dotup" # Only now, for the convenience of the rest of this script. export PATH="$HOME/.local/bin:$PATH" echo "1. public tier applied: $(chezmoi managed -p absolute | wc -l) files" # absence assertions BEFORE the private tier exists [ ! -e "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh present pre-bootstrap" [ ! -e "$HOME/.config/bitwarden/bws-token" ] || fail "bws token present pre-bootstrap" [ ! -d "$HOME/.local/share/dotfiles-private" ] || fail "private tier present pre-bootstrap" echo "2. public-only machine is clean: no token, no secrets, no private source" # ---- 2. select the private rows -------------------------------------------- STATE=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles mkdir -p "$STATE" printf 'core/unzip\nprivate/private-repo\nprivate/bws-secrets\n' > "$STATE/selected" echo " plan says:"; dotup plan 2>&1 | sed 's/^/ /' | grep -v '^ *$' | head -8 # ---- 3. install: unzip via apt. bws must NOT appear here ------------------- dotup --yes install 2>&1 | sed 's/^/ /' | tail -8 command -v unzip >/dev/null || fail "unzip not installed" command -v bws >/dev/null && fail "bws installed by the package phase — it must come from the private tier" echo "3. unzip installed; bws correctly absent (it is not a manifest package)" # ---- 4. the private tier, through the real endpoint ------------------------ # TEN answers, not three: dotup asks for the endpoint URL, username and # password, and then the private repo's .chezmoi.toml.tmpl asks its own seven # promptStringOnce questions. # # They must be answered one at a time, as each prompt appears. Two earlier # attempts piped all ten in up front through `script` and hung forever on # `git user.name?`. chezmoi's prompt is a full-screen TUI -- it puts the tty in # raw mode, and a raw-mode switch done with TCSAFLUSH DISCARDS input already # sitting in the buffer. Everything pre-fed was written to the pty before # chezmoi started, so it was thrown away, and the pty then waited on a stdin # that had already closed. No amount of pre-feeding can work against that # prompt; the answers have to arrive after it draws. # # expect also drops `script`'s worst property: script mirrors its stdin into # its own output, which published the endpoint password into a session # transcript once already. Credentials reach expect through the environment # ($env(...)), never argv, never stdin. red() { sed -e "s|$BOOT_URL||g" -e "s|$BOOT_PW||g" \ -e 's|[0-9a-f]\{32\}||g'; } export E2E_NAME='E2E Test' E2E_EMAIL='e2e@example.invalid' timeout 600 expect -f - > /tmp/private.log 2>&1 <<'EXP' set timeout 180 log_user 1 spawn -noecho dotup private # A bare `expect -re {pat} { ... }` treats a timeout as "carry on", so a missed # prompt does not fail -- it silently falls through and every later expect waits # out its own timeout in turn, surfacing at the 600s ceiling as an unattributed # hang. Name the prompt that was actually missed instead. proc wait_for {pat what} { expect { -re $pat {} timeout { send_user "\nTIMEOUT: never saw $what\n"; exit 3 } eof { send_user "\nEOF before $what -- dotup exited early\n"; exit 4 } } } # dotup's own three: plain `read`, no TUI, so the text is the whole signal 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 -- "$env(BOOT_PW)\r" # The private repo's seven, each answered only once its TUI is genuinely ready # to read. # # Matching the prompt TEXT is not enough, and this is a race that passes most of # the time. chezmoi writes "git user.email?" while the tty is still in cooked # mode, and only then switches to raw mode -- with TCSAFLUSH, which DISCARDS # whatever is already sitting in the input buffer. An answer sent on the text # alone can therefore land in the window before the switch and be thrown away. # The value is gone, the field sits unsubmitted, and every later expect waits # out its own timeout: one lost keystroke costs the full 600s ceiling and # reports as "a prompt went unanswered". # # Observed exactly that way -- one run answered all seven, the next lost the # Enter after user.email. Nothing about the two runs differed but timing. # # `\033[?2004h` is bracketed-paste-on, which the TUI emits only AFTER raw mode # is established. Waiting for it turns "probably ready" into "demonstrably # ready". Each prompt emits its own, so this is per-answer, not once. proc ask {pat val} { wait_for $pat "chezmoi prompt $pat" wait_for "\033\\\[\\?2004h" "raw mode after $pat (TUI never became ready)" send -- "$val\r" } ask {user\.name} "$env(E2E_NAME)" ask {user\.email} "$env(E2E_EMAIL)" # blank accepts the template's default, which is what a real operator does for # the signing key and the four gitea URLs ask {signing key} "" ask {WAN ssh} "" ask {LAN ssh} "" ask {WAN web} "" ask {LAN web} "" expect eof catch wait result exit [lindex $result 3] EXP rc=$? case $rc in 124) fail "dotup private hit the 600s ceiling — expect itself wedged, which its own timeout handlers should have prevented" ;; 3|4) red < /tmp/private.log | tr -d '\r' | tail -6 | sed 's/^/ /' fail "a prompt went unanswered — see the TIMEOUT/EOF line above for which one" ;; esac # `script` mirrors its stdin into the pty output, so the three answers -- the # secret path and the password among them -- are sitting at the top of that log. # Redact before anything prints it. Learned the hard way: an earlier run of this # test published both into a session transcript and they had to be rotated. red < /tmp/private.log | tr -d '\r' | sed 's/^/ /' | tail -14 # ---- 5. assertions --------------------------------------------------------- echo echo "--- verification ---" command -v bws >/dev/null || fail "bws not installed by the private tier" echo "bws: $(bws --version 2>&1 | head -1), installed by the private tier" [ -r "$HOME/.config/bitwarden/bws-token" ] || fail "bws token not written" m=$(stat -c '%a' "$HOME/.config/bitwarden/bws-token") [ "$m" = 600 ] || fail "bws token mode $m, want 600" echo "bws token: present, mode 600" [ -d "$HOME/.local/share/dotfiles-private" ] || fail "private source not cloned" sm=$(stat -c '%a' "$HOME/.local/share/dotfiles-private") case $sm in *00) echo "private source: cloned, mode $sm (go-rwx applied)" ;; *) fail "private source mode $sm — group/other can read it" ;; esac [ -f "$HOME/.config/chezmoi/private.toml" ] || fail "private.toml not created" [ -f "$HOME/.config/chezmoi/chezmoi.toml" ] || fail "public chezmoi.toml missing" d=$(grep -c '^\s*git' "$HOME/.config/chezmoi/private.toml" 2>/dev/null || echo 0) echo "configs: chezmoi.toml + private.toml, private has $d identity keys" [ -r "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh not generated" sm=$(stat -c '%a' "$HOME/.config/zsh/secrets.zsh") [ "$sm" = 600 ] || fail "secrets.zsh mode $sm, want 600" n=$(grep -c '^export ' "$HOME/.config/zsh/secrets.zsh") echo "secrets.zsh: mode 600, $n exports (want 8)" [ "$n" -eq 8 ] || fail "expected 8 exports, got $n" # the credential must not be in any log dotup produced if grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' /tmp/private.log; then echo "FAIL: a credential-bearing URL appears in dotup output"; exit 1 fi echo "log hygiene: no credential-bearing URL in dotup output" # ...nor in the clone's own config, which is where it used to live forever. GC="$HOME/.local/share/dotfiles-private/.git/config" [ -f "$GC" ] || fail "no .git/config in the private source" grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' "$GC" \ && fail "the git remote in .git/config still carries the token" echo "git remote: clean — $(git -C "$HOME/.local/share/dotfiles-private" remote get-url origin)" CRED="${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/private-credentials" [ -r "$CRED" ] || fail "credential file not written to $CRED" cm=$(stat -c '%a' "$CRED") [ "$cm" = 600 ] || fail "credential file mode $cm, want 600" echo "credential file: present, mode 600, $(wc -l < "$CRED") line" # the whole point: a later update must still authenticate, with the token only # ever coming from that file git -C "$HOME/.local/share/dotfiles-private" fetch -q 2>/dev/null \ && echo "later fetch: authenticates from the credential file" \ || fail "fetch failed — the credential helper did not survive the clone" # identity arrived git config --get user.email >/dev/null 2>&1 || fail "git identity not configured" echo "git identity: configured" zsh -ic 'exit' >/dev/null 2>&1 && echo "zsh: interactive login clean" \ || echo "WARN: zsh -ic exited non-zero" echo echo "E2E PASS"