diff --git a/.tests/lab/Dockerfile b/.tests/lab/Dockerfile new file mode 100644 index 0000000..c801c88 --- /dev/null +++ b/.tests/lab/Dockerfile @@ -0,0 +1,42 @@ +# A box shaped like one you would actually be handed, not one shaped to make +# the tests pass. +# +# Three details are load-bearing, and each of them hid a real bug: +# +# non-root user the old harness ran as root, where ~/.local/bin, sudo and +# $HOME all behave differently. +# bash login Ubuntu's own ~/.profile prepends ~/.local/bin ONLY if that +# directory already exists when the shell starts. A session +# opened before the install therefore does NOT have it, which +# is precisely why `dotup` came back "command not found" on a +# real machine and never once in CI. +# curl but no git the cloud images ship curl; git arrives with chezmoi's +# installer or not at all. +# +# Nothing else is pre-installed. Anything the tests need beyond this -- expect, +# zsh -- is installed by the scenario that needs it, so a dependency can never +# be silently satisfied by the image. +ARG BASE=ubuntu:24.04 +FROM ${BASE} + +ARG USER=ben +ARG UID=1001 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends \ + ca-certificates curl sudo locales tzdata \ + && locale-gen en_US.UTF-8 >/dev/null 2>&1 \ + && rm -rf /var/lib/apt/lists/* + +# `ubuntu` already owns 1000 in 24.04, so take the next id rather than fighting +# it. Passwordless sudo matches a cloud image; the installer must never assume +# it, but it must work when it is there. +RUN useradd -m -u ${UID} -s /bin/bash ${USER} \ + && printf '%s ALL=(ALL) NOPASSWD:ALL\n' "${USER}" > /etc/sudoers.d/90-${USER} \ + && chmod 440 /etc/sudoers.d/90-${USER} + +ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 +USER ${USER} +WORKDIR /home/${USER} +CMD ["sleep", "infinity"] diff --git a/.tests/lab/check-verbatim.sh b/.tests/lab/check-verbatim.sh new file mode 100755 index 0000000..51adb6c --- /dev/null +++ b/.tests/lab/check-verbatim.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# The fake private repo copies three files verbatim from the real one, so the +# scenarios measure the shipping implementation rather than a paraphrase of it. +# A verbatim copy silently stops being verbatim the moment the original is +# edited -- which happened within the hour of creating it. Check, don't hope. +set -eu +cd "$(dirname "$0")" +REAL=${1:-/home/dev/code/dotfiles-private} +rc=0 +for f in .chezmoiignore run_after_50-secrets.sh.tmpl \ + dot_local/bin/private_executable_dotsecrets.tmpl; do + if [ ! -f "$REAL/$f" ]; then + echo "MISSING in the real repo: $f" >&2; rc=1; continue + fi + if ! diff -q "$REAL/$f" "fake-private/$f" >/dev/null; then + echo "DRIFTED: fake-private/$f no longer matches $REAL/$f" >&2 + echo " fix: cp '$REAL/$f' '$PWD/fake-private/$f'" >&2 + rc=1 + fi +done +[ "$rc" -eq 0 ] && echo "fake-private: all three verbatim copies match the real repo" +exit $rc diff --git a/.tests/lab/fake-private/.chezmoi.toml.tmpl b/.tests/lab/fake-private/.chezmoi.toml.tmpl new file mode 100644 index 0000000..f7b12b8 --- /dev/null +++ b/.tests/lab/fake-private/.chezmoi.toml.tmpl @@ -0,0 +1,36 @@ +# chezmoi configuration for the PRIVATE tier. +# +# Seven questions, asked once at `chezmoi init` and never again -- promptStringOnce +# reads the value already in the config if there is one. Nothing here is fetched +# over a network: your own name is not a rotatable secret, and making `apply` +# depend on an API call to learn your email address would be absurd. +# +# There is deliberately no `encryption` key and no [age] section. The one secret +# in this repo is an ordinary 600-mode file; see README.md for why that is the +# cheaper answer than a per-machine root secret that was already 0 bytes on one +# machine in three. +# +# WHERE THIS FILE LANDS -- read before changing the aliases. +# `dotup` runs `chezmoi init --apply --source ~/.local/share/dotfiles-private +# -c ~/.config/chezmoi/private.toml `, so this template renders to +# private.toml and the public tier keeps chezmoi.toml. The `cmp` alias carries +# the same -c. Two sources, two configs, no overlap. +# +# It was not always so, and the bug is worth remembering. Both tiers rendered +# to the DEFAULT config path, so re-running the public installer overwrote this +# file and took the seven answers below with it. Nothing failed at the time: +# the templates degrade politely when their data is missing -- config.local +# emits a comment telling you to re-run init rather than failing the apply -- +# so the symptom was `git commit` not knowing who you are, days later, with +# nothing pointing back at the install that caused it. A loud failure would +# have been a smaller bug. + +[data] + gitName = {{ promptStringOnce . "gitName" "git user.name" | quote }} + gitEmail = {{ promptStringOnce . "gitEmail" "git user.email" | quote }} + gitSigningKey = {{ promptStringOnce . "gitSigningKey" "git signing key id (blank for none)" "" | quote }} + + giteaWanSsh = {{ promptStringOnce . "giteaWanSsh" "gitea WAN ssh prefix" "ssh://git@git.example.invalid:222/" | quote }} + giteaLanSsh = {{ promptStringOnce . "giteaLanSsh" "gitea LAN ssh prefix" "ssh://git@10.99.99.99:2223/" | quote }} + giteaWanWeb = {{ promptStringOnce . "giteaWanWeb" "gitea WAN web url" "https://git.example.invalid/" | quote }} + giteaLanWeb = {{ promptStringOnce . "giteaLanWeb" "gitea LAN web url" "http://10.99.99.99:3001/" | quote }} diff --git a/.tests/lab/fake-private/.chezmoidata/bws.toml b/.tests/lab/fake-private/.chezmoidata/bws.toml new file mode 100644 index 0000000..a971add --- /dev/null +++ b/.tests/lab/fake-private/.chezmoidata/bws.toml @@ -0,0 +1,43 @@ +# Fake stand-in for the private tier's bws map. Same STRUCTURE as the real one +# -- seven secrets and one alias -- because `dotsecrets` is copied verbatim and +# the "8 exports, not 7" assertion depends on that cardinality. +# +# The ids are sequential and obviously synthetic. They are not secrets in the +# real repo either (a UUID fetches nothing without the machine token), but a +# fake tree should be unmistakably fake at a glance. +[bws] +project = "00000000-0000-4000-8000-000000000000" + +[[bws.secrets]] +env = "LAB_ALPHA_API_KEY" +id = "00000000-0000-4000-8000-000000000001" + +[[bws.secrets]] +env = "LAB_BRAVO_API_KEY" +id = "00000000-0000-4000-8000-000000000002" + +[[bws.secrets]] +env = "LAB_CHARLIE_API_KEY" +id = "00000000-0000-4000-8000-000000000003" + +[[bws.secrets]] +env = "LAB_DELTA_API_KEY" +id = "00000000-0000-4000-8000-000000000004" + +[[bws.secrets]] +env = "LAB_ECHO_API_KEY" +id = "00000000-0000-4000-8000-000000000005" + +[[bws.secrets]] +env = "LAB_FOXTROT_API_KEY" +id = "00000000-0000-4000-8000-000000000006" + +[[bws.secrets]] +env = "LAB_GOLF_API_KEY" +id = "00000000-0000-4000-8000-000000000007" + +# One value under two names, exactly as ZAI_API_KEY aliases Z_AI_API_KEY. This +# is why the real machine has 7 secrets and 8 exports. +[[bws.aliases]] +name = "LAB_GOLF_ALIAS_KEY" +from = "LAB_GOLF_API_KEY" diff --git a/.tests/lab/fake-private/.chezmoiignore b/.tests/lab/fake-private/.chezmoiignore new file mode 100644 index 0000000..b06adc4 --- /dev/null +++ b/.tests/lab/fake-private/.chezmoiignore @@ -0,0 +1,91 @@ +# .chezmoiignore -- PRIVATE tier. +# +# This is gitignore syntax. A `#` in the middle of a line becomes part of the +# pattern, silently producing an entry that matches nothing, so every comment +# in this file is on its own line. Learned in phase 1; it does not announce +# itself. +# +# Ignoring changes what chezmoi manages. It never removes or modifies a file +# on any machine. + +# Repo documentation and the endpoint artifact, not dotfiles. Without these +# they land as ~/README.md and ~/bootstrap.env.example. +README.md +bootstrap.env.example + +# Runbook scripts for the bootstrap endpoint. Operator tooling that is run by +# hand a few times a year, not configuration that belongs in a home directory. +# They live here rather than in the public tier because they name the endpoint +# host and route, and the public repo is cloneable by strangers. +ops +ops/** + +# chezmoi's own config directory. Never manage the thing that configures the +# manager. +.config/chezmoi +.config/chezmoi/** + +# Nested git checkouts. +**/.git +**/.git/** + +# Generated at apply time by run_after_50-secrets.sh, never tracked anywhere. +# If this line is ever removed, `chezmoi add` on a finished machine sweeps the +# seven API keys straight into the repo. +.config/zsh/secrets.zsh + +# The per-machine delta that Q5 puts opposite the base settings.json below. +# Claude Code writes this one; chezmoi must never fight it. +.claude/settings.local.json +.claude.json + +# `bws` writes a 600-mode state cache here on its first successful call -- +# ~/.config/bws/state/, about 2 KB. It is derived from the access token +# and belongs in no repository. Found by watching what appeared in a throwaway +# home directory after the first apply, not by reading the documentation. +.config/bws +.config/bws/** + +# The bws access token itself. `dotup` writes this from the bootstrap endpoint +# before this repo is even cloned, so chezmoi has no business managing it: a +# managed copy would overwrite a freshly-rotated token with a stale committed +# one on the next apply. It was committed exactly once, in phase 4, by a +# `chezmoi add` of the live file. Listing it here makes chezmoi decline the +# same `add` rather than accept it silently. +.config/bitwarden +.config/bitwarden/** + +# ---------------------------------------------------------------- ssh --- +# Deny-by-default, then name the three things that travel. Q3 is answered as +# "sync public keys, not private": public keys are not secret, private keys +# never cross a network and are generated per machine. +# +# The wildcard is the point. An id_ed25519 generated on this machine tomorrow +# matches `.ssh/*` and is matched by no negation below, so `chezmoi add ~/.ssh` +# cannot sweep it up. New key files are excluded by default rather than +# remembered about. +# +# ~/.ssh/known_hosts is excluded by the same rule, deliberately: it is a log of +# where this machine has been, it rewrites itself constantly, and syncing it +# would make `chezmoi status` permanently dirty. +# +# NOTE THE ABSENCE of `!.ssh/*.pub`. Public keys do travel -- that is what +# ~/.ssh/pubkeys/ is for -- but NOT at ~/.ssh/id_ed25519.pub, and the +# distinction is not pedantry: +# +# Every machine generates its own keypair. If this tier also wrote +# id_ed25519.pub, that machine would end up with ITS private key sitting +# next to SOMEONE ELSE'S public key under the matching name. `ssh-copy-id` +# and every agent-only auth path read the .pub, so you would authorise the +# wrong machine and watch it appear to work. +# +# The practical value of syncing public keys is authorized_keys -- a new box +# accepting the keys you already have, with nothing to paste. That is carried +# in full. ~/.ssh/pubkeys/*.pub is the archive of the keys you own, for pasting +# into GitHub and Gitea, parked where ssh will never mistake one for the local +# identity. +.ssh/* +!.ssh/config +!.ssh/authorized_keys +!.ssh/pubkeys +!.ssh/pubkeys/*.pub diff --git a/.tests/lab/fake-private/dot_claude/settings.json b/.tests/lab/fake-private/dot_claude/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.tests/lab/fake-private/dot_claude/settings.json @@ -0,0 +1 @@ +{} diff --git a/.tests/lab/fake-private/dot_local/bin/private_executable_dotsecrets.tmpl b/.tests/lab/fake-private/dot_local/bin/private_executable_dotsecrets.tmpl new file mode 100644 index 0000000..3391248 --- /dev/null +++ b/.tests/lab/fake-private/dot_local/bin/private_executable_dotsecrets.tmpl @@ -0,0 +1,213 @@ +#!/bin/sh +# dotsecrets -- regenerate ~/.config/zsh/secrets.zsh from Bitwarden Secrets Manager. +# +# Needs no endpoint, no username and no password. The bootstrap exchange already +# happened; what it left behind is ~/.config/bitwarden/bws-token, mode 600, and +# that token is the whole input to this command. Run it whenever you rotate a +# key in bws. `cmp apply` runs it too, via run_after_50-secrets.sh. +# +# THE ONE INVARIANT: a failed or partial fetch leaves a working secrets.zsh +# exactly as it was. Every value is fetched into a 600-mode temp file first, +# and that file is renamed over the real one only after all of them have +# arrived. Six keys out of seven is a machine that was fine a moment ago and +# now silently cannot reach one provider -- worse than a machine that says the +# refresh failed and carries on with yesterday's keys. +# +# The temp file is created in the SAME DIRECTORY as the destination, not in +# /tmp. `mv` across filesystems is copy-then-unlink, which has a window where +# the destination is half-written; within one filesystem it is rename(2), which +# has none. The atomicity this whole script is built around is a property of +# rename(2), not of the word "mv". +# +# NOTHING IS EVER PRINTED. No value reaches stdout, stderr, argv or a log: +# - values move from `bws` into a shell variable and from there into a file +# through the `printf` BUILTIN, so they never appear in `ps`; +# - bws's own stderr is discarded, because an error message is not worth the +# risk of it quoting what it was handed; +# - every failure message below names the ENV VAR, never the value. +# +# Generated by chezmoi from the PRIVATE tier. The env-var -> secret-id map is +# .chezmoidata/bws.toml; UUIDs are identifiers, not secrets. + +set -u + +PROG=dotsecrets +CFG="${XDG_CONFIG_HOME:-$HOME/.config}" +TOKEN_FILE="$CFG/bitwarden/bws-token" +OUT="$CFG/zsh/secrets.zsh" +TMP="" + +warn() { printf '%s: %s\n' "$PROG" "$*" >&2; } + +cleanup() { [ -n "$TMP" ] && rm -f "$TMP"; return 0; } +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM HUP + +# Bail out without touching OUT. This is the entire point of the command. +abort() { + warn "$1" + if [ -r "$OUT" ]; then + warn "keeping the existing $OUT -- it was NOT modified" + else + warn "$OUT was not written; the shell starts without those keys" + fi + exit 1 +} + +# POSIX single-quoting using builtins only, so a value never becomes an +# argument to an external command and never becomes a line in `ps` output. +# API keys do not contain apostrophes, but a quoting routine that is correct +# only for the inputs you happen to have is not a quoting routine. +shquote() { + _sq_s=$1 + _sq_o='' + while :; do + case $_sq_s in + *"'"*) ;; + *) break ;; + esac + _sq_o="$_sq_o${_sq_s%%\'*}'\\''" + _sq_s=${_sq_s#*\'} + done + printf "'%s%s'" "$_sq_o" "$_sq_s" +} + +# ------------------------------------------------------------ preconditions --- + +[ -r "$TOKEN_FILE" ] || abort "no bws token at $TOKEN_FILE (public-only machine?)" + +command -v bws >/dev/null 2>&1 \ + || abort "bws is not installed or not on PATH -- https://bitwarden.com/help/secrets-manager-cli/" + +BWS_ACCESS_TOKEN="$(cat "$TOKEN_FILE")" +[ -n "$BWS_ACCESS_TOKEN" ] || abort "$TOKEN_FILE is empty" +export BWS_ACCESS_TOKEN + +mkdir -p "$CFG/zsh" || abort "cannot create $CFG/zsh" +chmod 700 "$CFG/zsh" 2>/dev/null || : + +# ---------------------------------------------------------------- the map --- +# `env var name` `bws secret id`, rendered from .chezmoidata/bws.toml so that a +# UUID is written down in exactly one place and it is not this script. +SECRET_MAP='{{ range .bws.secrets }} +{{ .env }} {{ .id }}{{ end }}' + +# Second names for a value fetched once. One secret, two exported names: some +# tools spell it Z_AI_API_KEY and some spell it ZAI_API_KEY. +ALIAS_MAP='{{ range .bws.aliases }} +{{ .name }} {{ .from }}{{ end }}' + +# ------------------------------------------------------------- the fetch --- + +umask 077 +TMP="$(mktemp "$CFG/zsh/.secrets.zsh.XXXXXXXX")" || abort "cannot create a temp file beside $OUT" +chmod 600 "$TMP" || abort "cannot chmod the temp file" + +{ + printf '# Generated from Bitwarden Secrets Manager. DO NOT EDIT, DO NOT COMMIT.\n' + printf '# Regenerate with `dotsecrets`. Mode 600, in no repository.\n' + printf '# Last refreshed: %s\n\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +} >"$TMP" || abort "cannot write to the temp file" + +# `set -f` because the maps are split on IFS by `set --`, and an unglobbed +# split would let a stray `*` in the source data expand against the cwd. +# The loops run in this shell, not in a pipeline subshell, so `count` and the +# remembered values survive them -- a `while read` on the right of a pipe is +# the classic way to lose exactly the state this script needs. +set -f +count=0 +NL="$(printf '\n_')" +NL=${NL%_} + +# shellcheck disable=SC2086 +set -- $SECRET_MAP +while [ "$#" -ge 2 ]; do + name=$1 + id=$2 + shift 2 + + # The name becomes part of a variable name below. It comes from a file in + # this repo rather than from anywhere a stranger can reach, but a shell + # variable name is close enough to code that it gets checked anyway. + case $name in + [A-Za-z_]*) ;; + *) abort "invalid env var name in the secret map: $name" ;; + esac + case $name in + *[!A-Za-z0-9_]*) abort "invalid env var name in the secret map: $name" ;; + esac + + # -o env prints `KEY=VALUE`, where KEY is the secret's own name in bws. + # Comparing it to the name we asked for is a free integrity check on the + # map: an id that points OPENAI_API_KEY at the Groq secret is caught here + # rather than six months later as a confusing 401. + line="$(bws secret get "$id" -o env 2>/dev/null)" \ + || abort "could not fetch $name from bws (no network, or the token is wrong or revoked)" + + # First line only, trimmed with parameter expansion rather than `head` or + # `sed`: keeping the value out of every external process's stdin as well + # as its argv costs one case statement. + case $line in + *"$NL"*) line=${line%%"$NL"*} ;; + esac + + case $line in + "$name"=*) ;; + *) abort "bws returned a different secret than $name -- check its id in .chezmoidata/bws.toml" ;; + esac + + value=${line#"$name"=} + [ -n "$value" ] || abort "bws returned an empty value for $name" + + printf 'export %s=%s\n' "$name" "$(shquote "$value")" >>"$TMP" \ + || abort "cannot write to the temp file" + + # Remember it for the alias pass. The value is expanded by the assignment, + # not by `eval` -- eval only ever parses the variable NAME. + eval "_v_$name=\$value" + + count=$((count + 1)) +done + +[ "$count" -gt 0 ] || abort ".chezmoidata/bws.toml carries no entries -- nothing to fetch" + +# --------------------------------------------------------------- aliases --- +# After the loop, so an alias can only reference a value that has already +# arrived intact. +alias_count=0 +# shellcheck disable=SC2086 +set -- $ALIAS_MAP +while [ "$#" -ge 2 ]; do + alias_name=$1 + source_name=$2 + shift 2 + alias_count=$((alias_count + 1)) + + eval "aliased=\${_v_$source_name:-}" + [ -n "$aliased" ] \ + || abort "alias $alias_name names $source_name, which is not in the secret map" + + printf '\n# same value, second name expected by some tools\n' >>"$TMP" \ + || abort "cannot write to the temp file" + printf 'export %s=%s\n' "$alias_name" "$(shquote "$aliased")" >>"$TMP" \ + || abort "cannot write to the temp file" +done +set +f + +# ----------------------------------------------------------------- commit --- +# Everything arrived. Only now does the real file change, and it changes in one +# rename rather than a truncate followed by a write. +chmod 600 "$TMP" || abort "cannot chmod the temp file" +mv -f "$TMP" "$OUT" || abort "cannot rename the temp file into place" +TMP="" + +# $count is secrets FETCHED; aliases add further exports without another +# fetch. Reporting only the first number against a file with more lines than +# that reads like a bug in the generator. Say both. +if [ "$alias_count" -gt 0 ]; then + warn "wrote $OUT ($count secrets + $alias_count alias(es) = $((count + alias_count)) exports, mode 600)" +else + warn "wrote $OUT ($count secrets, mode 600)" +fi +exit 0 diff --git a/.tests/lab/fake-private/private_dot_config/git/config.local.tmpl b/.tests/lab/fake-private/private_dot_config/git/config.local.tmpl new file mode 100644 index 0000000..75c11cd --- /dev/null +++ b/.tests/lab/fake-private/private_dot_config/git/config.local.tmpl @@ -0,0 +1,71 @@ +{{- $name := get . "gitName" -}} +{{- $email := get . "gitEmail" -}} +{{- $signing := get . "gitSigningKey" -}} +{{- $wan := get . "giteaWanSsh" -}} +{{- $lan := get . "giteaLanSsh" -}} +; ~/.config/git/config.local -- PRIVATE tier. +; +; The other half of the seam. ~/.gitconfig comes from the public repo, carries +; no [user], and ends with `[include] path = ~/.config/git/config.local`. Git +; treats a missing include as a no-op, so a public-only machine reads the +; public half and stops -- and `git commit` correctly refuses to guess who you +; are. +; +; Everything here is identity, not configuration: it is the answer to "whose +; machine is this", which is exactly the question the public tier must not be +; able to answer. +; +; Values come from the [data] prompts in .chezmoi.toml.tmpl, asked once at +; `chezmoi init`. Re-answer them with `cmp init` (see README). + +{{ if and $name $email -}} +[user] + name = {{ $name }} + email = {{ $email }} +{{- if $signing }} + signingkey = {{ $signing }} +[commit] + gpgsign = true +[tag] + gpgsign = true +{{- end }} +{{- else -}} +; NO IDENTITY CONFIGURED. +; +; gitName and/or gitEmail are empty in the chezmoi config, which means either +; you pressed enter through the prompts or something overwrote +; ~/.config/chezmoi/chezmoi.toml after this tier was initialised. Re-run: +; +; chezmoi init -S ~/.local/share/dotfiles-private +; +; Until then git will refuse to commit, which is the correct complaint. +{{- end }} + +; Every remote in every repo you own is ssh. This rewrite is what lets a +; copy-pasted https:// GitHub URL clone over the key you actually have, which +; matters most on a machine built ten minutes ago. It lived in the old +; dot_gitconfig; phase 3 removed it from the public tier because it names an +; authentication method tied to your keys, not a neutral default. +[url "git@github.com:"] + insteadOf = https://github.com/ + +{{ if $wan -}} +; Gitea clone/push shortcuts: git clone gitea:ben/repo.git +[url "{{ $wan }}"] + insteadOf = gitea: +{{ end -}} +{{ if $lan -}} +[url "{{ $lan }}"] + insteadOf = gitea-lan: +{{ end }} +{{- if and $wan $lan }} +[alias] + ; Configure `origin` to push to BOTH gitea servers at once. Run once inside + ; a repo whose origin points at either gitea host: + ; + ; git dual-gitea + ; + ; After this, `git push` writes to WAN + LAN simultaneously. Fetch/pull + ; continues to use origin's existing fetch URL. + dual-gitea = "!f() { url=$(git remote get-url origin) || { echo 'no origin remote' >&2; return 1; }; p=${url#gitea:}; p=${p#gitea-lan:}; p=${p#{{ $wan }}}; p=${p#{{ $lan }}}; if [ \"$p\" = \"$url\" ]; then echo \"origin is not a gitea remote: $url\" >&2; return 1; fi; git config --unset-all remote.origin.pushurl 2>/dev/null; git remote set-url --add --push origin \"gitea:$p\"; git remote set-url --add --push origin \"gitea-lan:$p\"; echo 'Dual-push configured on origin:'; git remote -v; }; f" +{{- end }} diff --git a/.tests/lab/fake-private/private_dot_config/private_zsh/private_local.zsh.tmpl b/.tests/lab/fake-private/private_dot_config/private_zsh/private_local.zsh.tmpl new file mode 100644 index 0000000..713c16a --- /dev/null +++ b/.tests/lab/fake-private/private_dot_config/private_zsh/private_local.zsh.tmpl @@ -0,0 +1,61 @@ +{{- $wanWeb := get . "giteaWanWeb" -}} +{{- $lanWeb := get . "giteaLanWeb" -}} +# ~/.config/zsh/local.zsh -- PRIVATE tier. Mode 600. +# +# Sourced by the public ~/.zshrc, guarded, near the end: +# +# [[ -r ${XDG_CONFIG_HOME:-$HOME/.config}/zsh/local.zsh ]] && source ... +# +# Absent on a public-only machine, where the guard makes it a silent no-op. +# `_mac` and `_open` from .zshrc are still in scope by the time this runs, so +# an alias moved here needs no rewriting. +# +# What belongs here: anything that names a host you own. Nothing that is a +# secret -- those come from ~/.config/zsh/secrets.zsh, written by `dotsecrets` +# and carried in no repository at all. +# +# Mode 600 rather than 644, for the same reason .zshrc and .zshenv are: this is +# code your login shell executes, and a group-writable copy of it is arbitrary +# code execution for anyone in your primary group. + +# --------------------------------------------------------------- gitea --- +# The web UI. The WAN host is a real name behind TLS; the LAN one is a bare +# address on a port, reachable only from the house, which is precisely why it +# cannot live in the public tier. +{{ if $wanWeb }}alias gitea='_open {{ $wanWeb }}'{{ end }} +{{ if $lanWeb }}alias gitea-lan='_open {{ $lanWeb }}'{{ end }} + +# The clone/push shortcuts are git-side, not shell-side: `gitea:` and +# `gitea-lan:` are url.insteadOf rewrites in ~/.config/git/config.local, so +# `git clone gitea:ben/repo.git` works from any shell, not just this one. + +# ------------------------------------------------------------- chezmoi --- +# Two instances, one home directory. The public tier lays the base; the private +# tier overlays identity on top. Both are ordinary chezmoi invocations with a +# different --source, so every subcommand you know still works: +# +# cm status / cm diff / cm re-add ~/.zshrc public +# cmp status / cmp diff / cmp apply private +# +# Each tier has its own source AND its own config file: +# +# public `chezmoi init --apply `, no flags -> ~/.local/share/chezmoi +# ~/.config/chezmoi/chezmoi.toml +# private dotup, --source ... -c .../private.toml -> ~/.local/share/dotfiles-private +# ~/.config/chezmoi/private.toml +# +# The -c is not cosmetic. Both tiers used to render their .chezmoi.toml.tmpl to +# the same default path, so re-running the PUBLIC installer overwrote the config +# holding this tier's seven promptStringOnce answers -- name, email, signing key, +# the gitea addresses -- and they were gone. Silently: the templates degrade +# politely when their data is missing, so the first symptom was `git commit` not +# knowing who you are, days later and unconnected to the install that caused it. +# +# -S and -c must stay in step with dotup's PRIV_SRC and PRIV_CFG. If you move one, +# move the other, or cmp reads a config that describes a different source tree. +alias cm='chezmoi' +alias cmp='chezmoi -S ${XDG_DATA_HOME:-$HOME/.local/share}/dotfiles-private -c ${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/private.toml' + +# Refresh ~/.config/zsh/secrets.zsh from Bitwarden Secrets Manager on demand. +# `cmp apply` does the same thing via run_after_50-secrets.sh. +# dotsecrets diff --git a/.tests/lab/fake-private/private_dot_ssh/private_authorized_keys b/.tests/lab/fake-private/private_dot_ssh/private_authorized_keys new file mode 100644 index 0000000..c06da63 --- /dev/null +++ b/.tests/lab/fake-private/private_dot_ssh/private_authorized_keys @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBNye8EHJ7ijGBNbmvvY2DqzZ8pd88vlI4OOYcM7ZxBK lab@example.invalid diff --git a/.tests/lab/fake-private/private_dot_ssh/private_config.tmpl b/.tests/lab/fake-private/private_dot_ssh/private_config.tmpl new file mode 100644 index 0000000..fb70b12 --- /dev/null +++ b/.tests/lab/fake-private/private_dot_ssh/private_config.tmpl @@ -0,0 +1,57 @@ +{{- $wan := get . "giteaWanSsh" | trimPrefix "ssh://git@" | trimSuffix "/" -}} +{{- $lan := get . "giteaLanSsh" | trimPrefix "ssh://git@" | trimSuffix "/" -}} +{{- $wanParts := $wan | splitList ":" -}} +{{- $lanParts := $lan | splitList ":" -}} +{{- $wanHost := $wanParts | first -}} +{{- $lanHost := $lanParts | first -}} +{{- $wanPort := ternary ($wanParts | last) "22" (gt (len $wanParts) 1) -}} +{{- $lanPort := ternary ($lanParts | last) "22" (gt (len $lanParts) 1) -}} +# ~/.ssh/config -- PRIVATE tier. Mode 600. +# +# HOST ALIASES ONLY. No key material of any kind travels in this repository, +# in this tier or any other. Q3 is answered "sync public keys, not private": +# +# travels ~/.ssh/config, ~/.ssh/authorized_keys, ~/.ssh/pubkeys/*.pub +# never id_ed25519, id_rsa, *.pem, anything without a .pub suffix +# +# The IdentityFile lines below name a key this machine generates for itself: +# +# ssh-keygen -t ed25519 -C "$(whoami)@$(hostname)" +# +# then paste ~/.ssh/id_ed25519.pub into GitHub and Gitea once. Ninety seconds +# per machine, and a private key never crosses a network. .chezmoiignore denies +# everything under .ssh/ by default and re-includes exactly three things, so a +# key generated tomorrow cannot be swept in by a careless `chezmoi add ~/.ssh`. + +Host * + AddKeysToAgent yes + ServerAliveInterval 60 + ServerAliveCountMax 3 + HashKnownHosts no + +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +{{ if $wanHost }} +# Gitea over the WAN. A real hostname behind a real certificate; reachable +# from any network, which is what makes it the primary for both repos. +Host gitea {{ $wanHost }} + HostName {{ $wanHost }} + Port {{ $wanPort }} + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +{{ end }} +{{- if $lanHost }} +# The same Gitea, reached over the LAN on a different port. Faster at home and +# the only route if the WAN name is down. A bare address on a private network: +# the single most obviously non-public line in this whole system. +Host gitea-lan {{ $lanHost }} + HostName {{ $lanHost }} + Port {{ $lanPort }} + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +{{ end }} diff --git a/.tests/lab/fake-private/private_dot_ssh/pubkeys/dev.pub b/.tests/lab/fake-private/private_dot_ssh/pubkeys/dev.pub new file mode 100644 index 0000000..c06da63 --- /dev/null +++ b/.tests/lab/fake-private/private_dot_ssh/pubkeys/dev.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBNye8EHJ7ijGBNbmvvY2DqzZ8pd88vlI4OOYcM7ZxBK lab@example.invalid diff --git a/.tests/lab/fake-private/run_after_50-secrets.sh.tmpl b/.tests/lab/fake-private/run_after_50-secrets.sh.tmpl new file mode 100644 index 0000000..46a88fe --- /dev/null +++ b/.tests/lab/fake-private/run_after_50-secrets.sh.tmpl @@ -0,0 +1,44 @@ +#!/bin/sh +# run_after_50-secrets.sh -- PRIVATE tier. +# +# Regenerate ~/.config/zsh/secrets.zsh at the end of every `cmp apply`. +# +# The `run_after_` prefix is load-bearing. It guarantees this runs once every +# managed file is on disk, which resolves the deadlock the old repo had: the +# old .zshrc fetched secrets on line 62 using a token that line 58's file had +# not written yet. Ordering by prefix rather than by hope. +# +# This script is a wrapper and nothing else. The work lives in `dotsecrets`, +# which you can also run by hand after rotating a key in bws -- one +# implementation, so the scheduled path and the manual path cannot drift apart +# and start disagreeing about what a valid secrets.zsh looks like. +# +# IT ALWAYS EXITS 0. A machine on a train with no signal must still be able to +# finish an apply. `dotsecrets` leaves an existing secrets.zsh untouched when +# it cannot fetch, so the failure mode here is "yesterday's keys and a warning" +# rather than "the apply died half way through". + +set -u +{{ if ne .chezmoi.destDir .chezmoi.homeDir }} +# Rendered only when this apply is aimed somewhere other than the home +# directory -- `chezmoi apply --destination /tmp/whatever`, which is how this +# tier gets tested. `dotsecrets` resolves its own paths from $HOME, so running +# it here would reach straight past the throwaway destination and rewrite the +# real ~/.config/zsh/secrets.zsh. A test that mutates the machine it is +# protecting is not a test. +printf 'run_after_50-secrets: destination is {{ .chezmoi.destDir }}, not the home directory; skipping\n' >&2 +exit 0 +{{ end }} +DOTSECRETS="{{ .chezmoi.homeDir }}/.local/bin/dotsecrets" + +if [ ! -x "$DOTSECRETS" ]; then + printf 'run_after_50-secrets: %s is missing or not executable; skipping\n' \ + "$DOTSECRETS" >&2 + exit 0 +fi + +if ! "$DOTSECRETS"; then + printf 'run_after_50-secrets: refresh failed (see above). The apply itself is fine.\n' >&2 +fi + +exit 0 diff --git a/.tests/lab/run.sh b/.tests/lab/run.sh new file mode 100755 index 0000000..f480c7d --- /dev/null +++ b/.tests/lab/run.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Run one scenario against the working tree, in a container, offline. +# +# run.sh [--image IMG] [--keep] [--priv REPO] +# +# Everything the real system talks to is stood in locally: the bootstrap +# endpoint, both git remotes, and the secrets manager. Nothing here touches the +# real endpoint, the real repos, or the network, so a scenario can be run as +# often as it takes without publishing anything or spending a password. +# +# The code under test is the CURRENT WORKING TREE, committed or not. +set -euo pipefail +cd "$(dirname "$0")" +LAB=$PWD +PUB=$(CDPATH= cd -- ../.. && pwd) +PRIV_DEFAULT=$LAB/fake-private + +scenario=""; IMAGE=dotup-lab:24.04; KEEP=0; PRIV=$PRIV_DEFAULT +while [ $# -gt 0 ]; do + case $1 in + --image) IMAGE=$2; shift 2 ;; + --keep) KEEP=1; shift ;; + --priv) PRIV=$2; shift 2 ;; + -*) echo "unknown flag $1" >&2; exit 2 ;; + *) scenario=$1; shift ;; + esac +done +[ -n "$scenario" ] || { echo "usage: run.sh [--image IMG] [--keep]" >&2; exit 2; } +[ -f "$scenario" ] || { echo "no such scenario: $scenario" >&2; exit 2; } +name=$(basename "$scenario" .sh) + +# Distinct per run. The route and password are secrets in production, so the +# lab never reuses a value and never hardcodes one -- a scenario that only +# passes against a fixed password is testing the fixture. +rand() { head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n'; } +ROUTE=r-$(rand); PASS=$(rand); GIT_TOKEN=$(rand); USER_=ben +GW=$(docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}') +# Left unset so the server takes any free port and reports it back; several +# scenarios run at once and a fixed port makes them collide. +PORT=${LAB_PORT:-0} +ROOT=$(mktemp -d /tmp/dotup-lab.XXXXXX) +C=dotup-lab-$name-$$ + +cleanup() { + rc=$? + [ -n "${SRV:-}" ] && kill "$SRV" 2>/dev/null || : + if [ "$KEEP" = 1 ]; then + echo "kept: container $C lab root $ROOT" >&2 + else + docker rm -f "$C" >/dev/null 2>&1 || : + rm -rf "$ROOT" + fi + exit $rc +} +trap cleanup EXIT INT TERM + +echo "== lab: snapshotting working trees ==" +sh snapshot.sh "$PUB" "$ROOT/git" dotfiles-public >/dev/null +sh snapshot.sh "$PRIV" "$ROOT/git" dotfiles-private >/dev/null +echo " public: $(git -C "$ROOT/git/dotfiles-public.git" ls-tree -r --name-only HEAD | wc -l) files" +echo " private: $(git -C "$ROOT/git/dotfiles-private.git" ls-tree -r --name-only HEAD | wc -l) files ($PRIV)" + +export LAB_GIT_ROOT=$ROOT/git LAB_PORT=$PORT LAB_ROUTE=$ROUTE \ + LAB_USER=$USER_ LAB_PASS=$PASS LAB_GIT_TOKEN=$GIT_TOKEN LAB_BIND=$GW +# The blob is byte-for-byte the shape the real endpoint returns: two KEY=VALUE +# lines, the repo URL carrying an inline token that the installer has to split +# out into a credential file. +# {PORT} is filled in by the server once it has bound one. The blob is +# otherwise byte-for-byte the shape the real endpoint returns: two KEY=VALUE +# lines, the repo URL carrying an inline token the installer must split out. +export LAB_BLOB="PRIVATE_REPO_URL=http://git:$GIT_TOKEN@$GW:{PORT}/git/dotfiles-private.git +BWS_ACCESS_TOKEN=lab-bws-$(rand) +" +python3 serve.py >"$ROOT/serve.log" 2>&1 & SRV=$! +for _ in $(seq 40); do + PORT=$(sed -n 's/^lab: listening on [^:]*:\([0-9]*\).*/\1/p' "$ROOT/serve.log") + [ -n "$PORT" ] && [ "$PORT" != 0 ] && break + sleep 0.25 +done +[ -n "$PORT" ] && [ "$PORT" != 0 ] || { echo "lab server never reported a port:"; cat "$ROOT/serve.log"; exit 1; } +for _ in $(seq 40); do + curl -sf -o /dev/null -u "$USER_:$PASS" "http://$GW:$PORT/$ROUTE/bootstrap.env" && break + sleep 0.25 +done +curl -sf -o /dev/null -u "$USER_:$PASS" "http://$GW:$PORT/$ROUTE/bootstrap.env" \ + || { echo "lab server never came up:"; cat "$ROOT/serve.log"; exit 1; } +echo " endpoint up on $GW:$PORT" + +docker image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "== lab: building $IMAGE =="; docker build -q -t "$IMAGE" -f Dockerfile . >/dev/null; } +docker rm -f "$C" >/dev/null 2>&1 || : +docker run -d --name "$C" --add-host lab:"$GW" "$IMAGE" >/dev/null +docker cp "$scenario" "$C:/tmp/scenario.sh" >/dev/null +[ -d assets ] && docker cp assets "$C:/tmp/assets" >/dev/null + +echo "== lab: $name on $IMAGE ==" +# Bare -e names inherit from this shell, so the password and the git token never +# appear in docker's argv -- /proc//cmdline is world readable, which is the +# same hole this repo was fixed to stop opening. +export BOOT_URL="http://$GW:$PORT/$ROUTE" BOOT_USER=$USER_ BOOT_PW=$PASS \ + PUB_URL="http://$GW:$PORT/git/dotfiles-public.git" +set +e +docker exec -u ben \ + -e BOOT_URL -e BOOT_USER -e BOOT_PW -e PUB_URL \ + -e HOME=/home/ben -e LANG=en_US.UTF-8 \ + "$C" bash /tmp/scenario.sh +rc=$? +set -e +# Redact before anything is printed or kept: a scenario log that quoted the +# password back would be as bad as committing it. +sed -i -e "s|$PASS||g" -e "s|$GIT_TOKEN||g" -e "s|$ROUTE||g" \ + "$ROOT/serve.log" 2>/dev/null || : +echo "== lab: $name exit $rc ==" +exit $rc diff --git a/.tests/lab/scenarios/00-smoke.sh b/.tests/lab/scenarios/00-smoke.sh new file mode 100644 index 0000000..8f69b5b --- /dev/null +++ b/.tests/lab/scenarios/00-smoke.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Does the lab itself work? Clone the public tier from the local server exactly +# as the README's first command does, and prove the code that arrived is the +# working tree rather than whatever was last pushed. +set -u +fail() { echo "FAIL: $*"; exit 1; } +ok() { echo " ok $*"; } + +echo "-- environment as handed to a real user --" +echo " user=$(id -un) uid=$(id -u) shell=$SHELL home=$HOME" +echo " PATH=$PATH" +case ":$PATH:" in *":$HOME/.local/bin:"*) + fail "~/.local/bin is already on PATH -- the box is not fresh, and the + 'command not found' bug cannot reproduce here" ;; +esac +ok "~/.local/bin is NOT on PATH yet (matches a real fresh login)" +command -v git >/dev/null && fail "git pre-installed -- image is too generous" +ok "git absent, as on a stock image" + +echo "-- the documented first command --" +# ISSUE-1 regression guard: NOTHING is installed by hand here. git is absent, +# and the documented command has to cope with that on its own. If this scenario +# ever needs an `apt-get install git` again, the front door has re-broken. +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \ + || { tail -20 /tmp/init.log; fail "chezmoi init --apply"; } +ok "public tier applied — from a box with no git, unaided" +grep -q 'installing git' /tmp/init.log \ + || fail "git was installed, but not by run_before_00-require-git.sh — + something else is satisfying the prerequisite and the fix is untested" +ok "run_before_00-require-git.sh is what supplied git" +command -v git >/dev/null || fail "git still missing after the apply" +n=$(ls -d "$HOME/.oh-my-zsh" "$HOME/.tmux" 2>/dev/null | wc -l) +[ "$n" -eq 2 ] || fail "the git-repo externals did not clone ($n/2 present)" +ok "the six git-repo externals cloned" + +[ -x "$HOME/.local/bin/dotup" ] || fail "no executable at ~/.local/bin/dotup" +ok "dotup landed at ~/.local/bin/dotup" + +# The whole point of the lab: prove we are running uncommitted code. The +# snapshot commit message is the marker, and it cannot exist on any real remote. +src=$(~/bin/chezmoi source-path 2>/dev/null || chezmoi source-path 2>/dev/null || echo "$HOME/.local/share/chezmoi") +msg=$(git -C "$src" log -1 --format=%s 2>/dev/null || echo none) +case $msg in + "working-tree snapshot of dotfiles-public") ok "serving the WORKING TREE, not a pushed commit" ;; + *) fail "expected the working-tree snapshot commit, got: $msg" ;; +esac + +echo "-- and the bug, reproduced --" +if command -v dotup >/dev/null 2>&1; then + fail "bare 'dotup' resolved in this shell -- expected 'command not found'" +fi +ok "bare 'dotup' is command-not-found in the shell that ran the install" +if bash -lc 'command -v dotup' >/dev/null 2>&1; then + ok "a NEW login shell does resolve it (~/.profile picks up ~/.local/bin)" + grep -q 'Next: ~/.local/bin/dotup' /tmp/init.log \ + || fail "the install never told the user what to run next, so the only + way to find out is to type 'dotup' and be told it does not exist" + ok "the install printed the absolute path to run next, and why" +else + fail "even a new login shell cannot find dotup" +fi +echo "SMOKE PASS" diff --git a/.tests/lab/scenarios/10-manifest-install.sh b/.tests/lab/scenarios/10-manifest-install.sh new file mode 100644 index 0000000..db1e04e --- /dev/null +++ b/.tests/lab/scenarios/10-manifest-install.sh @@ -0,0 +1,310 @@ +#!/bin/bash +# What a real user actually gets. +# +# The picker's ^a is `preset defaults`, and Enter is `install`. This scenario is +# that keystroke pair with nobody at the keyboard: tick the defaults, resolve +# every ticked row through dotup's own `resolve`, run dotup's own installer, and +# then ask the machine -- not the log -- whether each package is there. +# +# The log is not evidence. `apt-get install -y` prints a great deal and still +# leaves you without the package; a batch that fails silently retries one at a +# time and the second failure scrolls past the first. So every row is verified +# against the filesystem or the package database afterwards, and anything that +# did not land is RE-RUN ON ITS OWN through dotup, which does three things at +# once: it gets the exact command dotup would use, the exact exit code, and the +# exact first line of the error, with no other package's output interleaved. +# +# The re-run also settles the question a single pass cannot: a package that +# fails in the batch and installs on its own was a flake or a batch fault, not +# an unavailable package. Those come back as FLAKE-RECOVERED and are reported +# separately, so a transient 503 from a mirror is never filed as a bug. +# +# Output: a tab-separated table on stdout, one row per selected package. +# STATUS KEY CHANNEL ARG ISORC ERROR +set -u + +fail() { echo "FAIL: $*"; exit 1; } +ok() { echo " ok $*"; } + +STATE=$HOME/.config/dotfiles +SEL=$STATE/selected +DOTUP=$HOME/.local/bin/dotup +LOG=/tmp/dotup-lab +mkdir -p "$LOG" + +# ------------------------------------------------------------------ preamble - +echo "-- preamble --" +# ISSUE-1: the documented one-liner used to die on a stock box, because chezmoi's +# six git-repo externals need git and the image has none. Try it UNAIDED first -- +# if the repo now supplies git itself, installing it here would hide that and +# hide any future regression. Only fall back to the hand-install, loudly, if the +# documented command still cannot stand on its own. +if sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >"$LOG/init.log" 2>&1; then + if grep -q 'installing git' "$LOG/init.log"; then + ok "ISSUE-1 fixed upstream: the repo installed git itself" + else + ok "chezmoi init --apply succeeded (git was already satisfied)" + fi +else + echo " NOTE ISSUE-1 still open: init --apply failed on a box with no git" + sudo apt-get update -qq >/dev/null 2>&1 + sudo apt-get install -y -qq git >/dev/null 2>&1 || fail "could not install git" + echo " NOTE installed git by hand and retried -- see ISSUE-1" + sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >"$LOG/init.log" 2>&1 \ + || { tail -20 "$LOG/init.log"; fail "chezmoi init --apply"; } +fi +[ -x "$DOTUP" ] || fail "no dotup at $DOTUP" +ok "public tier applied; dotup at $DOTUP" +# The tree under test moves while this suite is being written, so pin which +# build produced the table below. Two runs that disagree are only interesting +# if they ran the same code. +echo " dotup sha256 $(sha256sum "$DOTUP" | cut -c1-16) manifest sha256 $(sha256sum "$HOME/.local/share/dotup/packages.tsv" | cut -c1-16)" + +# A user on a desktop has a display, and `preset defaults` ticks the gui rows +# only when one is present -- so ^a on a headless box selects a DIFFERENT set. +# Say which of the two we are testing instead of inheriting whatever the +# container happens to be. +export DISPLAY=:99 + +# --------------------------------------------------------------- selection -- +echo "-- selection: ^a (preset defaults) --" +"$DOTUP" preset defaults || fail "preset defaults" +nsel=$(grep -c . "$SEL") +nsafe=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="safe"' "$HOME/.local/share/dotup/packages.tsv" | wc -l) +ngui=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="gui"' "$HOME/.local/share/dotup/packages.tsv" | wc -l) +echo " manifest: safe=$nsafe gui=$ngui -> expected $((nsafe + ngui))" +echo " selected: $nsel" +[ "$nsel" -eq $((nsafe + ngui)) ] || fail "defaults preset did not tick safe+gui" +ok "$nsel packages ticked" + +# -------------------------------------------------------------- resolution -- +# dotup's own resolver, one key at a time, so the table below is what dotup +# decided rather than what this scenario guessed. +echo "-- resolution (dotup resolve) --" +: > "$LOG/resolved.tsv" +while read -r key; do + [ -n "$key" ] || continue + line=$("$DOTUP" resolve "$key" 2>/dev/null) + IFS=$'\t' read -r ch arg <<<"$line" + [ -n "${arg:-}" ] || arg="-" + printf '%s\t%s\t%s\n' "$key" "${ch:-?}" "$arg" >> "$LOG/resolved.tsv" +done < "$SEL" +echo " channel spread:" +awk -F'\t' '{c[$2]++} END{for(k in c) printf " %-12s %d\n", k, c[k]}' "$LOG/resolved.tsv" | sort + +"$DOTUP" plan >"$LOG/plan.log" 2>&1 || : +"$DOTUP" --print --yes install >"$LOG/print.log" 2>&1 || : +ok "plan and dry-run captured" + +# ----------------------------------------------------------------- install -- +echo "-- install: dotup --yes install (this takes a while) --" +t0=$(date +%s) +"$DOTUP" --yes install >"$LOG/install.log" 2>&1 +irc=$? +t1=$(date +%s) +echo " exit $irc after $((t1 - t0))s, $(wc -l <"$LOG/install.log") lines of log" + +echo "-- what dotup itself said did not install --" +sed -n '/did not install/,$p' "$LOG/install.log" | sed 's/\x1b\[[0-9;]*m//g' | sed 's/^/ | /' + +# ------------------------------------------------------------ verification -- +# Ask the machine, not the log. +UV=$(command -v uv 2>/dev/null || echo "$HOME/.local/bin/uv") + +dpkg_ok() { [ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" = installed ]; } + +# The same search path dotup's own find_tool uses, and for the same reason: a +# tool installed a moment ago is not on this process's PATH. Checking only PATH +# and ~/.local/bin reported chezmoi missing when get.chezmoi.io had put it in +# ~/bin -- a false failure against dotup for a fault in the check. +have_bin() { + local c + command -v "$1" >/dev/null 2>&1 && return 0 + for c in "$HOME/.local/bin/$1" "$HOME/bin/$1" "$HOME/.npm-global/bin/$1" \ + "/usr/local/bin/$1" "/usr/local/go/bin/$1" \ + "/home/linuxbrew/.linuxbrew/bin/$1" "/opt/homebrew/bin/$1"; do + [ -x "$c" ] && return 0 + done + return 1 +} +npm_ok() { + local root + root=$(npm root -g 2>/dev/null) || return 1 + [ -n "$root" ] && [ -e "$root/$1" ] +} + +verify() { + local key=$1 ch=$2 arg=$3 n + # The bespoke channels land somewhere only their handler knows about. + case $key in + core/neovim) [ -x /opt/nvim/bin/nvim ]; return $? ;; + core/go) [ -x /usr/local/go/bin/go ]; return $? ;; + core/chezmoi) have_bin chezmoi; return $? ;; + core/uv) have_bin uv; return $? ;; + apps/chrome) dpkg_ok google-chrome-stable; return $? ;; + apps/ghostty) dpkg_ok ghostty; return $? ;; + esac + case $ch in + apt) for n in $arg; do dpkg_ok "$n" || return 1; done; return 0 ;; + brew) command -v brew >/dev/null 2>&1 || return 1 + brew list --formula "$arg" >/dev/null 2>&1; return $? ;; + npm) for n in $arg; do npm_ok "$n" || return 1; done; return 0 ;; + uv) [ -x "$UV" ] || return 1 + "$UV" tool list 2>/dev/null | grep -q "^$arg "; return $? ;; + # Both of these are bounded on purpose. `snap list` does not fail fast when + # snapd is installed but not running -- it blocks trying to reach a daemon + # systemd never started -- and a verification step that can hang forever is + # not a verification step. + snap) command -v snap >/dev/null 2>&1 || return 1 + timeout 20 snap list "$arg" >/dev/null 2>&1; return $? ;; + flatpak) command -v flatpak >/dev/null 2>&1 || return 1 + timeout 20 flatpak --user info "$arg" >/dev/null 2>&1 && return 0 + timeout 20 flatpak info "$arg" >/dev/null 2>&1; return $? ;; + *) return 1 ;; + esac +} + +# Re-run ONE key through dotup, with its own state directory so the selection is +# exactly that key. Everything -- resolution, channel dispatch, the command +# string, sudo -- is dotup's, so what comes back is dotup's behaviour for that +# package in isolation and not this scenario's reconstruction of it. +isolate() { + local key=$1 d rc + d=$(mktemp -d) + printf '%s\n' "$key" >"$d/selected" + : >"$d/expanded" + DOTUP_STATE=$d "$DOTUP" --yes install >"$LOG/iso.$(printf '%s' "$key" | tr / _).log" 2>&1 + rc=$? + rm -rf "$d" + return $rc +} + +isolog() { printf '%s/iso.%s.log' "$LOG" "$(printf '%s' "$1" | tr / _)"; } + +# The first line that looks like a diagnosis, falling back to the last thing +# said. apt says "E:", npm says "npm error", brew and snap just say it. +firstline() { + local f=$1 l + l=$(sed 's/\x1b\[[0-9;]*m//g' "$f" 2>/dev/null \ + | grep -m1 -aE 'E: |error|Error|ERROR|Unable to locate|not found|No such|Permission denied|refus|missing|unavailable|failed|Failed' ) + [ -n "$l" ] || l=$(sed 's/\x1b\[[0-9;]*m//g' "$f" 2>/dev/null | grep -a . | tail -1) + printf '%s' "$l" | sed 's/^[[:space:]+]*//' | cut -c1-150 +} + +echo +echo "-- verification and per-package isolation --" +: > "$LOG/table.tsv" +while IFS=$'\t' read -r key ch arg; do + if verify "$key" "$ch" "$arg"; then + printf 'OK\t%s\t%s\t%s\t-\t-\n' "$key" "$ch" "$arg" >> "$LOG/table.tsv" + continue + fi + isolate "$key"; rc=$? + if verify "$key" "$ch" "$arg"; then + printf 'FLAKE-RECOVERED\t%s\t%s\t%s\t%s\t%s\n' \ + "$key" "$ch" "$arg" "$rc" "$(firstline "$(isolog "$key")")" >> "$LOG/table.tsv" + else + printf 'FAIL\t%s\t%s\t%s\t%s\t%s\n' \ + "$key" "$ch" "$arg" "$rc" "$(firstline "$(isolog "$key")")" >> "$LOG/table.tsv" + fi +done < "$LOG/resolved.tsv" + +# --------------------------------------------------------------- the table -- +echo +echo "===== RESULT TABLE =====" +printf 'STATUS\tKEY\tCHANNEL\tARG\tISORC\tERROR\n' +sort -k1,1r -k2,2 "$LOG/table.tsv" +echo "===== END RESULT TABLE =====" + +echo +echo "===== COUNTS =====" +awk -F'\t' '{s[$1]++; if($1!="OK") c[$3]++} END{ + for (k in s) printf "%-16s %d\n", k, s[k] + printf "\nfailures by channel:\n" + for (k in c) printf " %-10s %d\n", k, c[k] }' "$LOG/table.tsv" +printf 'total%12s %d\n' "" "$(grep -c . "$LOG/table.tsv")" +echo "===== END COUNTS =====" + +echo +echo "===== RUNTIME SMOKE =====" +# Installed is not the same as usable, and the difference is not visible in any +# install log. apt's nodejs on 24.04 is 18.19.1; every npm row in this manifest +# declares node>=20. npm 9 only WARNS about a failed engine check, so the +# install exits 0 and leaves a tool that cannot start. A table that stopped at +# "the files are on disk" would score that as a success. +smoke() { + local b=$1 p="" c out rc first + shift + p=$(command -v "$b" 2>/dev/null) || p="" + if [ -z "$p" ]; then + for c in "$HOME/.local/bin/$b" "$HOME/bin/$b" "$HOME/.npm-global/bin/$b" \ + "/usr/local/bin/$b" "/usr/local/go/bin/$b"; do + [ -x "$c" ] && { p=$c; break; } + done + fi + [ -n "$p" ] || { printf 'ABSENT\t%s\t-\t-\n' "$b"; return; } + out=$("$p" "$@" 2>&1); rc=$? + first=$(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | grep -a . | head -1 | cut -c1-110) + if [ "$rc" -eq 0 ]; then printf 'RUNS\t%s\t%s\t%s\n' "$b" "$rc" "$first" + else printf 'BROKEN\t%s\t%s\t%s\n' "$b" "$rc" "$first"; fi +} +printf 'STATUS\tBINARY\tRC\tFIRSTLINE\n' +smoke node --version +smoke npm --version +smoke nvim --version +smoke go version +smoke uv --version +smoke chezmoi --version +smoke rg --version +smoke fdfind --version +smoke batcat --version +smoke eza --version +smoke gh --version +smoke mmdc --version +smoke codex --version +smoke pi --version +smoke specify --help +smoke btop --version +smoke lazygit --version +smoke bw --version +echo "===== END RUNTIME SMOKE =====" + +echo +echo "===== ISOLATION DETAIL (non-OK rows) =====" +awk -F'\t' '$1!="OK"{print $2}' "$LOG/table.tsv" | while read -r key; do + echo "--- $key ---" + sed 's/\x1b\[[0-9;]*m//g' "$(isolog "$key")" 2>/dev/null | grep -a . | tail -14 | sed 's/^/ /' +done +echo "===== END ISOLATION DETAIL =====" + +echo +echo "===== DOTUP PLAN =====" +sed 's/\x1b\[[0-9;]*m//g' "$LOG/plan.log" | sed 's/^/ /' +echo "===== END DOTUP PLAN =====" + +echo +echo "===== DRY RUN (dotup --print install) =====" +sed 's/\x1b\[[0-9;]*m//g' "$LOG/print.log" | sed 's/^/ /' +echo "===== END DRY RUN =====" + +# A dry run reports what WOULD happen. It must never invent a failure that +# exists only because nothing ran. core/brew probed `have brew` after an +# install step that --print had merely printed, so a perfectly clean plan +# reported "brew still not found after installing it". +# +# This assertion has to live in a container. On a developer box `find_tool` +# probes /home/linuxbrew/.linuxbrew/bin by ABSOLUTE path and finds a real brew, +# so `have brew` succeeds and the bug cannot reproduce -- the same assertion in +# the fast suite passed with the guard deliberately removed. +invented=$(sed 's/\x1b\[[0-9;]*m//g' "$LOG/print.log" \ + | sed -n '/did not install/,$p' | grep -E '^[[:space:]]+[a-z]+/[a-z]' || true) +if [ -n "$invented" ]; then + printf '%s\n' "$invented" | sed 's/^/ | /' + fail "the dry run reported failures for packages it never tried to install" +fi +ok "a dry run invents no failures" + +nfail=$(awk -F'\t' '$1=="FAIL"' "$LOG/table.tsv" | wc -l) +echo +echo "MANIFEST-INSTALL: $nsel selected, $nfail did not install" +exit 0 diff --git a/.tests/lab/scenarios/11-brew.sh b/.tests/lab/scenarios/11-brew.sh new file mode 100644 index 0000000..0ff7c33 --- /dev/null +++ b/.tests/lab/scenarios/11-brew.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# The three packages with no apt source at all: lazygit, omp, herdr. +# +# They are `safe` and pre-ticked, they resolve to brew, and until now nothing +# installed brew -- so `^a` promised three packages that failed on every fresh +# Linux box. Homebrew does run on Linux; it just has to be asked. +# +# This is deliberately its own scenario. The Homebrew installer pulls a large +# tree and takes minutes, which is not something to bolt onto the fast path. +set -u +fail() { echo "FAIL: $*"; exit 1; } +ok() { echo " ok $*"; } + +sudo apt-get update -qq && sudo apt-get install -y -qq git >/dev/null 2>&1 +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \ + || { tail -5 /tmp/init.log; fail "public tier init"; } +D=$HOME/.local/bin/dotup + +command -v brew >/dev/null && fail "brew already present — this box is not fresh" +ok "no brew on a stock box, which is the whole problem" + +S=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles; mkdir -p "$S" +# Tick ONLY lazygit. brew must arrive through the @needs closure, not because +# the scenario asked for it -- that is the property under test. +"$D" preset none >/dev/null +"$D" toggle p:core/lazygit >/dev/null +grep -qx 'core/brew' "$S/selected" \ + || fail "ticking core/lazygit did not pull in core/brew — the @needs edge is missing" +ok "ticking lazygit pulled in core/brew by itself" + +# Order matters as much as presence: core/brew is a `script` row, and script +# used to run AFTER brew, so brew would still have been missing when the three +# brew rows were attempted. +"$D" --print install 2>&1 | grep -n 'Homebrew/install\|brew install lazygit' > /tmp/order.txt +h=$(sed -n 's/^\([0-9]*\):.*Homebrew\/install.*/\1/p' /tmp/order.txt | head -1) +b=$(sed -n 's/^\([0-9]*\):.*brew install lazygit.*/\1/p' /tmp/order.txt | head -1) +[ -n "$h" ] && [ -n "$b" ] || fail "could not find both steps in the plan: $(cat /tmp/order.txt)" +[ "$h" -lt "$b" ] || fail "brew is installed AFTER the packages that need it (line $h vs $b)" +ok "the installer runs before the packages that need it" + +echo "-- installing, this is the slow part --" +"$D" --yes install >/tmp/install.log 2>&1 +rc=$? +tail -4 /tmp/install.log | sed 's/^/ | /' +[ "$rc" -eq 0 ] || fail "dotup install exited $rc" + +command -v brew >/dev/null 2>&1 || [ -x /home/linuxbrew/.linuxbrew/bin/brew ] \ + || fail "brew was not installed" +ok "brew installed to the linuxbrew prefix" +export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + +# /usr/local is the reason Homebrew has a reputation. On Linux it should be +# untouched, and that claim is worth checking rather than repeating. +[ -z "$(ls -A /usr/local/bin 2>/dev/null | grep -x 'brew' || true)" ] \ + || fail "brew wrote into /usr/local/bin" +ok "/usr/local is untouched — everything is under the linuxbrew prefix" + +command -v lazygit >/dev/null || fail "lazygit still not installed after brew arrived" +ok "lazygit installed: $(lazygit --version 2>&1 | head -1 | cut -c1-60)" + +grep -q 'did not install' /tmp/install.log \ + && { grep -A5 'did not install' /tmp/install.log | sed 's/^/ | /'; fail "packages still failed"; } +ok "nothing in the run failed" +echo "BREW PASS" diff --git a/.tests/lab/scenarios/20-picker.sh b/.tests/lab/scenarios/20-picker.sh new file mode 100644 index 0000000..d369ceb --- /dev/null +++ b/.tests/lab/scenarios/20-picker.sh @@ -0,0 +1,1056 @@ +#!/bin/bash +# The picker, driven the way a hand drives it. +# +# Nothing here calls `dotup toggle` or `dotup render` directly. The real fzf is +# launched in a real pty and real keys are pressed into it; every claim the +# README makes about the picker is then checked against two independent +# channels, and each assertion says which one it used: +# +# --listen what the picker is SHOWING. fzf answers from its own model, so +# "row 41 is ticked" is a fact rather than a guess about a redraw. +# the files what the picker WROTE. $DOTUP_STATE/{selected,expanded} is the +# whole model; a keystroke that does not move them did nothing. +# +# The terminal transcript is scraped for exactly one thing -- the header -- +# because that is the one string --listen cannot report. +# +# Unlike 00-smoke.sh this does NOT stop at the first failure. The point is a +# survey of what a real user hits, and an early exit hides the rest of it; so +# `fail` records and carries on, `die` is for a setup that cannot proceed, and +# the exit status is the number of failures. +# +# It exits non-zero today, and every failure it reports is the product's, not +# the harness's: BUG-1/2 (^t over a filter ticks invasive rows that are not on +# screen, and is not its own undo), BUG-3 (a deliberate empty selection is not +# restored), BUG-4/6 (no tty: fzf's raw error, and the defaults preset written +# by a picker that never drew), BUG-7 (an unreadable state file makes every +# keystroke a silent no-op). Fixing those turns this green. +set -u + +FAILS=0 +fail() { printf 'FAIL: %s\n' "$*"; FAILS=$((FAILS + 1)); } +die() { printf 'FAIL: %s\n' "$*"; printf 'FAIL: setup cannot continue\n'; exit 1; } +ok() { printf ' ok %s\n' "$*"; } +note() { printf ' NOTE %s\n' "$*"; } +head_() { printf '\n== %s ==\n' "$*"; } + +# ------------------------------------------------------------------ setup ---- +head_ "setup" +# expect is a TEST dependency and is installed here rather than baked into the +# image, so it can never silently satisfy something the product needs. +sudo apt-get update -qq >/dev/null 2>&1 +sudo apt-get install -y -qq expect >/dev/null 2>&1 || die "could not install expect" +command -v expect >/dev/null || die "expect is not on PATH after installing it" +ok "expect installed (test dependency, not a product one)" + +# ISSUE-2: ~/.local/bin is not on the PATH of the shell that ran the install, +# so dotup is invoked by absolute path throughout. +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \ + || { tail -20 /tmp/init.log; die "chezmoi init --apply"; } +D=$HOME/.local/bin/dotup +[ -x "$D" ] || die "no executable at $D" +ok "public tier applied; dotup at $D" + +MAN=$HOME/.local/share/dotup/packages.tsv +[ -f "$MAN" ] || die "no manifest at $MAN" + +# --------------------------------------------------------------- harness ----- +cat > /tmp/api.sh <<'SH' +#!/bin/sh +# Read fzf's LIVE state out of its own --listen HTTP API. +# +# api.sh keys every VISIBLE row's key, in screen order +# api.sh marks "" per visible row; mark is x, ~ or . +# api.sh count matchCount (rows the filter is showing) +# api.sh total totalCount +# api.sh pos cursor position, 0-based +# api.sh cur the key under the cursor +p=$1; a=$2 +j=$(curl -s --max-time 5 "localhost:$p/?limit=500") || exit 1 +[ -n "$j" ] || exit 1 +# Cut to the matches array: `current` repeats a row and `selected` is fzf's +# multi-select, and neither is "what is on screen". +m=$(printf '%s' "$j" | sed 's/.*"matches":\[//; s/\],"selected".*//') +case $a in +keys) printf '%s' "$m" | grep -o '\\t[pg]:[^"]*' | cut -c3- ;; +# One object per line first. A match object is NOT always +# {"index":N,"text":"..."} -- as soon as a query is typed fzf appends +# "positions":[...] to every match, so anchoring on the closing brace +# returns nothing at exactly the moment a filter is under test. +marks) printf '%s' "$m" | sed 's/{"index":/\n{"index":/g' \ + | sed -n 's/.*"text":"\([^"]*\)".*/\1/p' \ + | sed -n 's/^.*\[\(.\)\][^\\]*\\t\([pg]:[^"]*\)$/\1 \2/p' \ + | sed 's/^ /./' ;; +count) printf '%s' "$j" | grep -o '"matchCount":[0-9]*' | cut -d: -f2 ;; +total) printf '%s' "$j" | grep -o '"totalCount":[0-9]*' | cut -d: -f2 ;; +pos) printf '%s' "$j" | grep -o '"position":[0-9]*' | cut -d: -f2 ;; +cur) printf '%s' "$j" | sed 's/.*"current":{//; s/},"matches".*//' | grep -o '\\t[pg]:[^"]*' | cut -c3- ;; +raw) printf '%s\n' "$j" ;; +esac +SH + +cat > /tmp/screen.sh <<'SH' +#!/bin/sh +# The pty transcript with the escape sequences taken out, so a screen +# assertion reads the words a human sees rather than the cursor moves between. +sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b[()][A-B0-9]//g' \ + -e 's/\x1b[=>]//g' -e 's/\x1b\][^\x07]*\x07//g' /tmp/screen.raw 2>/dev/null | tr -d '\r' +SH + +cat > /tmp/drive.exp <<'TCL' +#!/usr/bin/expect -f +# Drive the real picker in a real pty with real keystrokes. +# +# drive.exp +# +# Every wait is NAMED and fails by name. A bare `expect -re {pat} {}` treats a +# timeout as "carry on", which turns a missed prompt into an unattributed hang +# three steps later. +# +# Every `expect` below is multi-line ON PURPOSE. The one-line form +# `expect { eof {...} timeout {...} }` matches NOTHING in expect 5.45.4 -- it +# returns having run no action at all, which is the same silent-timeout trap in +# a different costume. +set PORT [lindex $argv 0] +set STEPS [lindex $argv 1] +set ROWS [lindex $argv 2] +set COLS [lindex $argv 3] +set CMD [join [lrange $argv 4 end] " "] + +set timeout 25 +log_user 0 +set stty_init "rows $ROWS cols $COLS" +set ::lastmarks "" +set ::rc "-" +set ::sawEOF 0 +# The state directory under test, so `snap` can copy the files the picker is +# writing at the same instant it samples the screen. +set ::SNAP $env(DOTUP_SNAP_STATE) + +proc bail {msg} { + puts "FAIL: $msg" + catch { exec sh -c "curl -s -XPOST localhost:$::PORT -d abort >/dev/null 2>&1" } + exit 1 +} + +# Read from the pty without blocking. The buffer is finite and fzf's redraws +# are noisy; a poll loop that never reads eventually wedges the child. eof is +# RECORDED, not swallowed: once expect matches eof the spawn id is closed and +# every later expect on it raises. +proc pump {} { + if {$::sawEOF} { return } + if {[catch { + expect \ + -timeout 0 \ + -re {(?s).+} {} \ + timeout {} \ + eof { set ::sawEOF 1 } + }]} { set ::sawEOF 1 } +} + +proc api {what} { + if {[catch { exec sh /tmp/api.sh $::PORT $what } out]} { return "" } + return $out +} + +# A single read can catch a TRANSIENT. `reload` empties fzf's list for an +# instant before the new rows land, so one sample taken at the wrong moment +# says "0 rows" or "the old rows", and a wait that accepts the first difference +# it sees is off by one keystroke for the rest of the session. Two reads that +# agree are a settled screen. This cost two false failures to learn. +proc marks_settled {} { + set deadline [expr {[clock milliseconds] + 8000}] + while {[clock milliseconds] < $deadline} { + pump + set a [api marks] + after 200 + pump + set b [api marks] + if {$a eq $b && $a ne ""} { + # Cross-check the parse against fzf's own matchCount. A reader that + # quietly returns nothing becomes a fifteen-minute hang somewhere + # else; this turns it into one named failure, here. + set n [llength [split $a "\n"]] + set c [api count] + if {[string is integer -strict $c] && $n != $c} { + bail "marks_settled: parsed $n rows but fzf reports $c matches -- the --listen reader is out of step with fzf's JSON" + } + return $a + } + } + bail "marks_settled: the picker never held still for 8s" +} + +# The same settling, but tolerant: used BEFORE a keystroke, where the picker +# may legitimately be gone already (the key after an enter or a ^c). Returns +# empty instead of failing, so "quiet" and "dead" are not confused. +proc marks_quiet {} { + set deadline [expr {[clock milliseconds] + 6000}] + while {[clock milliseconds] < $deadline} { + pump + set a [api marks] + if {$a eq ""} { return "" } + after 200 + pump + set b [api marks] + if {$a eq $b} { return $a } + } + return "" +} + +# Named wait 1 -- the picker is up, has drawn rows, and has settled. +proc wait_ready {} { + set deadline [expr {[clock milliseconds] + 40000}] + while {[clock milliseconds] < $deadline} { + pump + set c [api count] + if {[string is integer -strict $c] && $c > 0} { + set m [marks_settled] + if {$m ne ""} { + set ::lastmarks $m + # fzf answers --listen before it is reading the keyboard; the + # very first keystroke is otherwise dropped, and the session + # then fails three steps later on a row that never opened. + after 700 + pump + return + } + } + after 250 + } + bail "wait_ready: the picker never answered --listen on port $::PORT" +} + +# Named wait 2 -- the keystroke changed what is on screen. Comparing fzf's own +# rendered rows proves BOTH that the state file moved AND that the reload +# landed; the README's "the counts move on the same keystroke" is exactly that, +# so it is the thing worth waiting on. +proc wait_change {label} { + set deadline [expr {[clock milliseconds] + 25000}] + while {[clock milliseconds] < $deadline} { + set m [marks_settled] + if {$m ne $::lastmarks} { set ::lastmarks $m; return } + after 200 + } + bail "$label: nothing on screen changed after the keystroke" +} + +# Named wait 3 -- text on the terminal. Only for what --listen cannot report. +proc wait_screen {label pat} { + for {set i 0} {$i < 60} {incr i} { + pump + if {[catch { exec sh /tmp/screen.sh } txt]} { set txt "" } + if {[regexp $pat $txt]} { return } + after 250 + } + bail "$label: never saw /$pat/ on the terminal" +} + +# Named wait 4 -- a line from the program AFTER fzf has exited (the plan, the +# confirm prompt, an error). A real expect belongs there: the output is +# line-oriented by then, so the buffer is the right place to look. +proc wait_for {label pat} { + expect { + -re $pat { return } + timeout { bail "$label: timed out waiting for /$pat/" } + eof { set ::sawEOF 1; bail "$label: exited before /$pat/" } + } +} + +# Put the cursor on a named row. Navigation is a FIXTURE, not the thing under +# test, and counting arrow keys down a list that changes length as groups open +# is a source of false failures -- so the cursor is placed over the API and +# every binding under test is still a real keypress. +proc goto {key} { + set ks [split [api keys] "\n"] + set i [lsearch -exact $ks $key] + if {$i < 0} { bail "goto $key: that row is not on screen" } + catch { exec sh -c "curl -s -XPOST localhost:$::PORT -d 'pos([expr {$i + 1}])' >/dev/null" } + for {set n 0} {$n < 40} {incr n} { + pump + if {[api cur] eq $key} { set ::lastmarks [marks_settled]; return } + after 100 + } + bail "goto $key: the cursor never landed on it" +} + +exec sh -c "rm -f /tmp/screen.raw; : > /tmp/screen.raw" +# `script` rather than a bare spawn: expect's own log_file records nothing +# under `log_user 0`, and the transcript is the only channel that shows the +# header. Same `script -qec` idiom .tests/listen-test.sh already uses. +eval spawn -noecho script -q -f -e -c [list $CMD] /tmp/screen.raw + +set fh [open $STEPS r] +while {[gets $fh line] >= 0} { + set line [string trim $line] + if {$line eq "" || [string index $line 0] eq "#"} { continue } + set verb [lindex $line 0] + set rest [lrange $line 1 end] + switch -- $verb { + ready { wait_ready } + key { + # Wait for the screen to go quiet BEFORE pressing, so the key can + # never land in the middle of the previous reload and be lost -- + # and so the baseline the next `wait` compares against is the + # state this key acted on, not an older one. + set m [marks_quiet] + if {$m ne ""} { set ::lastmarks $m } + send -- [subst -nocommands -novariables [lindex $rest 1]] + after 200 + } + type { foreach ch [split [lindex $rest 1] ""] { send -- $ch; after 80 } + after 500; pump; set ::lastmarks [marks_settled] } + at { goto [lindex $rest 0] } + wait { wait_change [lindex $rest 0] } + scr { wait_screen [lindex $rest 0] [lindex $rest 1] } + line { wait_for [lindex $rest 0] [lindex $rest 1] } + sleep { pump; after [lindex $rest 0]; pump } + abort { catch { exec sh -c "curl -s -XPOST localhost:$PORT -d abort >/dev/null" } } + snap { + set n [lindex $rest 0] + set f [open /tmp/snap.$n w]; puts $f [marks_settled]; close $f + set f [open /tmp/cur.$n w]; puts $f [api cur]; close $f + set f [open /tmp/cnt.$n w]; puts $f [api count]; close $f + catch { exec sh -c "cp -f '$::SNAP/selected' /tmp/sel.$n; cp -f '$::SNAP/expanded' /tmp/exp.$n" } + } + end { + set gone $::sawEOF + if {!$gone} { + if {[catch { + expect { + eof { set gone 1 } + timeout {} + } + }]} { set gone 1 } + } + if {!$gone} { bail "end: the picker never exited, 25s after the last key" } + catch { wait } res + set ::rc [lindex $res 3] + set f [open /tmp/rc w]; puts $f $::rc; close $f + } + default { bail "unknown step verb: $verb" } + } +} +close $fh + +if {$::rc eq "-"} { + catch { exec sh -c "curl -s -XPOST localhost:$PORT -d abort >/dev/null" } + if {!$::sawEOF} { + catch { + expect { + eof {} + timeout {} + } + } + } + catch { wait } res + set f [open /tmp/rc w]; puts $f [lindex $res 3]; close $f +} +puts "DRIVE OK" +TCL + +PORTN=0 +DRC=0 +# drive +drive() { + dname=$1; drows=$2; dcols=$3; dsteps=$4; shift 4 + PORTN=$((PORTN + 1)) + dport=$((22300 + PORTN)) + rm -f /tmp/rc + # --listen goes in via FZF_DEFAULT_OPTS so the picker's own argv is + # untouched: the fzf under test is the one dotup builds, not a variant. + FZF_DEFAULT_OPTS="--listen $dport" DOTUP_SNAP_STATE="$SNAPST" \ + expect -f /tmp/drive.exp "$dport" "$dsteps" "$drows" "$dcols" "$*" \ + > "/tmp/drive.$dname.log" 2>&1 + DRC=$? + cp -f /tmp/screen.raw "/tmp/screen.$dname.raw" 2>/dev/null || : + if [ "$DRC" -ne 0 ]; then + fail "session '$dname' did not complete: $(grep -m1 '^FAIL' "/tmp/drive.$dname.log" || echo "expect exited $DRC")" + return 1 + fi + return 0 +} +prc() { cat /tmp/rc 2>/dev/null || echo "-"; } +# mark of a key in a snapshot: x (all on), ~ (some on), . (off) +markof() { awk -F'\t' -v k="$2" '$2==k {print $1; found=1} END{ if(!found) print "?" }' "/tmp/snap.$1"; } +selhas() { grep -qxF "$2" "/tmp/sel.$1" 2>/dev/null; } +seln() { awk 'NF{n++} END{print n+0}' "/tmp/sel.$1" 2>/dev/null; } +# keys present in $1 but not $2 +seldiff() { comm -23 <(sort -u "/tmp/sel.$1") <(sort -u "/tmp/sel.$2"); } + +ST=/tmp/state +SNAPST=$ST +export DOTUP_STATE=$ST DOTUP_SNAP_STATE=$ST +fresh() { rm -rf "$ST"; mkdir -p "$ST"; : > "$ST/selected"; : > "$ST/expanded"; } + +# =========================================================== PROMISE 1 ======= +# "It brings its own copy into ~/.cache/dotup/ and invokes it by absolute path. +# PATH is never modified and ~/.local/bin is never written." +head_ "promise 1 — where fzf comes from" + +command -v fzf >/dev/null 2>&1 && fail "the image already has fzf; the preflight cannot be observed" +[ -e "$HOME/.cache/dotup" ] && fail "~/.cache/dotup exists before anything ran" + +before_bin=$(ls -A "$HOME/.local/bin" | sort | tr '\n' ' ') +before_rc=$(md5sum "$HOME/.profile" "$HOME/.bashrc" 2>/dev/null | md5sum) +before_path=$PATH + +pre=$("$D" preflight 2>&1) || fail "dotup preflight failed: $pre" +case $pre in +*"$HOME/.cache/dotup/fzf"*) ok "preflight resolves to ~/.cache/dotup/fzf" ;; +*) fail "preflight did not use the cache: $pre" ;; +esac +[ -x "$HOME/.cache/dotup/fzf" ] || fail "no executable fzf in ~/.cache/dotup" +"$HOME/.cache/dotup/fzf" --version >/dev/null 2>&1 || fail "the fetched fzf does not run" +ok "fzf $("$HOME/.cache/dotup/fzf" --version | awk '{print $1}') fetched into ~/.cache/dotup" + +after_bin=$(ls -A "$HOME/.local/bin" | sort | tr '\n' ' ') +after_rc=$(md5sum "$HOME/.profile" "$HOME/.bashrc" 2>/dev/null | md5sum) +[ "$before_bin" = "$after_bin" ] || fail "~/.local/bin changed across the fzf preflight: [$before_bin] -> [$after_bin]" +ok "~/.local/bin untouched by the preflight" +[ "$before_rc" = "$after_rc" ] || fail "the preflight edited ~/.profile or ~/.bashrc" +ok "no shell rc file was written" +[ "$before_path" = "$PATH" ] || fail "PATH changed across the preflight" +case ":$PATH:" in *":$HOME/.cache/dotup:"*) fail "the fzf cache was put on PATH" ;; esac +ok "PATH never mentions the cache" + +# "Your own fzf wins whenever it clears the floor" -- with the cache already +# populated, it does not. The cache is checked first, unconditionally. +mkdir -p /tmp/shim +printf '#!/bin/sh\necho "0.99.0 (devel)"\n' > /tmp/shim/fzf; chmod +x /tmp/shim/fzf +got=$(PATH=/tmp/shim:$PATH "$D" fzf-path 2>/dev/null) +if [ "$got" = "$HOME/.cache/dotup/fzf" ]; then + note "a system fzf 0.99.0 does NOT win once the cache exists — resolution is + cache-first, so the README's 'your own fzf wins whenever it clears the + floor' holds only until dotup has fetched once (BUG-5)" +else + ok "the system fzf wins over the cache" +fi +# With no cache, a system fzf above the floor must win and nothing may be fetched. +mv "$HOME/.cache/dotup" /tmp/cachehold +got=$(PATH=/tmp/shim:$PATH "$D" fzf-path 2>/dev/null) +[ "$got" = "/tmp/shim/fzf" ] || fail "with no cache, a system fzf 0.99.0 did not win (got '$got')" +[ -e "$HOME/.cache/dotup/fzf" ] && fail "a usable system fzf was present and dotup fetched anyway" +ok "with no cache, a system fzf above the floor wins and nothing is fetched" +# Below the floor it must say so and fetch its own. +printf '#!/bin/sh\necho "0.30.0 (devel)"\n' > /tmp/shim/fzf +out=$(PATH=/tmp/shim:$PATH "$D" preflight 2>&1) +case $out in +*"below the verified floor"*) ok "a system fzf under the 0.44.0 floor is refused, by name" ;; +*) fail "an fzf below the floor was accepted silently: $out" ;; +esac +rm -rf "$HOME/.cache/dotup"; mv /tmp/cachehold "$HOME/.cache/dotup" + +# =========================================================== PROMISE 2 ======= +# Every documented binding: space tick, tab open, ^t tick all shown, +# ^a defaults, ^x none, enter install. (^o open all is in the on-screen header +# but not in the README prose.) +head_ "promise 2 — the documented keybindings" +fresh +cat > /tmp/steps.keys <<'EOS' +ready +scr header {space tick tab open} +snap p2start +key ctrl-x \x18 +wait ctrl-x +snap p2none +key ctrl-a \x01 +wait ctrl-a +snap p2defaults +key ctrl-o \x0f +wait ctrl-o +snap p2openall +key ctrl-o \x0f +wait ctrl-o-again +snap p2closed +at g:media +key tab \t +wait tab +snap p2tab +key space \x20 +wait space-on-group +snap p2groupoff +key enter \r +line plan {plan} +line count {[0-9]+ packages} +end +EOS +if drive keys 44 220 /tmp/steps.keys "$D pick"; then + # header: the one screen scrape. --listen cannot report it. + scr=$(sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/screen.keys.raw | tr -d '\r') + case $scr in + *'$space tick'*) fail "the header renders with a literal \$ — the \$'...' bashism is back + (dot_local/bin/executable_dotup, cmd_pick; /bin/sh is dash here)" ;; + *) ok "header has no stray \$ (the dash bashism stays fixed)" ;; + esac + case $scr in + *'install\n'*) fail "the header ends with a literal backslash-n — bashism regression" ;; + *) ok "header has no literal backslash-n" ;; + esac + case $scr in *'enter install'*) ok "header advertises 'enter install'" ;; + *) fail "the header never reached the screen" ;; esac + + [ "$(seln p2none)" -eq 0 ] || fail "^x left $(seln p2none) rows ticked" + ok "^x none — selection emptied ($(seln p2start) -> 0)" + [ "$(seln p2defaults)" -gt 0 ] || fail "^a did not restore any selection" + ok "^a defaults — $(seln p2defaults) rows back" + o=$(cat /tmp/cnt.p2openall); c=$(cat /tmp/cnt.p2closed) + [ "$o" -gt "$c" ] || fail "^o did not open the tree ($c -> $o rows)" + ok "^o open all — $c rows -> $o rows" + [ "$c" -eq 11 ] || note "^o is a TOGGLE, not 'open all': the second press closed everything ($c rows)" + grep -qx media /tmp/exp.p2tab || fail "tab on g:media did not record media as expanded" + ok "tab open — g:media expanded, $(cat /tmp/cnt.p2tab) rows on screen" + if [ "$(markof p2groupoff g:media)" = "." ]; then + ok "space tick — space on a full group turned every child off" + else + fail "space on a full g:media left it marked '$(markof p2groupoff g:media)'" + fi + [ "$(prc)" = "0" ] || fail "enter did not exit the picker cleanly (rc=$(prc))" + ok "enter install — picker accepted and the plan printed (rc 0)" +else + fail "promise 2 session aborted" +fi + +# ^t needs its own session so the filtered case below starts from a known set. +fresh +cat > /tmp/steps.ctrlt <<'EOS' +ready +snap p2tA +key ctrl-t \x14 +wait ctrl-t +snap p2tB +abort +end +EOS +if drive ctrlt 44 220 /tmp/steps.ctrlt "$D pick"; then + a=$(seln p2tA); b=$(seln p2tB) + [ "$a" -ne "$b" ] || fail "^t with no filter changed nothing ($a -> $b)" + ok "^t tick all shown — $a -> $b rows ticked with no filter" + risky=$(comm -23 <(sort -u /tmp/sel.p2tB) <(sort -u /tmp/sel.p2tA) | while read -r k; do + awk -F'\t' -v k="$k" '!/^[#@]/ && NF>=3 && ($1"/"$2)==k && ($3=="invasive"||$3=="private") {print k}' "$MAN" + done | wc -l) + note "one ^t on the default collapsed screen ticks $risky invasive/private rows. + Every group row is 'shown', so 'tick all shown' means the whole manifest + -- kernel drivers, the docker daemon and the display manager included" +fi + +# =========================================================== PROMISE 3 ======= +# "Expand the row to the packages it covers; if every one is on, turn them all +# off, otherwise turn them all on." Group row, package row, filtered set. +head_ "promise 3 — the one toggle rule" + +fresh +"$D" preset defaults +cat > /tmp/steps.rule <<'EOS' +ready +key ctrl-o \x0f +wait open-all +at g:media +snap r0 +key space \x20 +wait media-off +snap r1 +key space \x20 +wait media-on +snap r2 +at p:media/sox +key space \x20 +wait sox-off +snap r3 +key space \x20 +wait sox-on +snap r4 +abort +end +EOS +if drive rule 44 220 /tmp/steps.rule "$D pick"; then + for k in media/ffmpeg media/sox media/p7zip; do + selhas r0 "$k" || fail "$k was not on at the start" + selhas r1 "$k" && fail "group row: all-on did not turn $k off" + selhas r2 "$k" || fail "group row: the second press did not turn $k back on" + done + ok "group row — all three of media went off together, then all back on" + [ "$(markof r1 g:media)" = "." ] || fail "g:media mark after all-off is '$(markof r1 g:media)'" + [ "$(markof r2 g:media)" = "x" ] || fail "g:media mark after all-on is '$(markof r2 g:media)'" + ok "group mark tracked it: x -> . -> x" + + selhas r3 media/sox && fail "package row: space did not untick media/sox" + selhas r3 media/ffmpeg || fail "package row: space on sox also unticked ffmpeg" + ok "package row — space on media/sox moved sox and nothing else" + [ "$(markof r3 g:media)" = "~" ] || fail "g:media should read '~' with 2 of 3 on, reads '$(markof r3 g:media)'" + ok "the group above it went tri-state '~' on the same keystroke" + selhas r4 media/sox || fail "the second space did not put media/sox back" + ok "and back again" +fi + +# The filtered case, verbatim from the README: +# "type nvidia, press ^t, and exactly the three rows you can see flip." +fresh +"$D" preset defaults +cat > /tmp/steps.filter <<'EOS' +ready +key ctrl-o \x0f +wait open-all +snap f0 +type q nvidia +snap f1 +key ctrl-t \x14 +wait ctrl-t-on +snap f2 +key ctrl-t \x14 +wait ctrl-t-off +snap f3 +abort +end +EOS +if drive filter 44 220 /tmp/steps.filter "$D pick"; then + vis=$(cut -f2 /tmp/snap.f1 | sort | tr '\n' ' ') + n=$(cat /tmp/cnt.f1) + [ "$n" -eq 3 ] || fail "typing 'nvidia' shows $n rows, README says three: $vis" + [ "$vis" = "p:gpu/container-toolkit p:gpu/cuda-toolkit p:gpu/nvidia-driver " ] \ + || fail "the three visible rows are not the gpu ones: $vis" + ok "--exact confines 'nvidia' to exactly three rows: $vis" + + added=$(seldiff f2 f1 | tr '\n' ' ') + if [ "$added" = "gpu/container-toolkit gpu/cuda-toolkit gpu/nvidia-driver " ]; then + ok "^t over the filter flipped exactly the rows on screen" + else + fail "^t over a filtered set ticked rows that are NOT on screen. + README: 'exactly the three rows you can see flip'. + Actually ticked: $added + The extras come from @needs gpu/container-toolkit docker, and every one + of them is flagged invasive — docker-ce's own note is 'the docker group + is root-equivalent'. Nothing on screen says it happened. (BUG-1)" + fi + offscreen=$(for k in $(seldiff f2 f1); do + grep -qxF "p:$k" <(cut -f2 /tmp/snap.f1) || echo "$k"; done) + inv=$(for k in $offscreen; do + awk -F'\t' -v k="$k" '!/^[#@]/ && NF>=3 && ($1"/"$2)==k && $3=="invasive" {print k}' "$MAN"; done | tr '\n' ' ') + if [ -n "$offscreen" ]; then + fail "the risk model says 'invasive is never ticked for you'. One ^t ticked + these rows that were NOT on screen: $(printf '%s' "$offscreen" | tr '\n' ' ') + and every one of them is invasive: $inv (BUG-1, same keystroke)" + fi + + # and the same keystroke twice in a row is not an undo + if diff -q <(sort -u /tmp/sel.f1) <(sort -u /tmp/sel.f3) >/dev/null; then + ok "^t then ^t returns to where it started" + else + still=$(seldiff f3 f1 | tr '\n' ' ') + fail "^t is not its own undo over a filter: after ^t ^t these are still + ticked that were not before: $still + Turning ON widens along @needs; turning OFF widens along the reverse + edges, and nothing needs the gpu rows — so the docker packages the first + press pulled in are never let go. (BUG-2)" + fi +fi + +# =========================================================== PROMISE 4 ======= +# "@needs closure runs in both directions ... the counts move on the same +# keystroke." +head_ "promise 4 — the @needs closure, both directions" +fresh +"$D" preset defaults +cat > /tmp/steps.needs <<'EOS' +ready +key ctrl-o \x0f +wait open-all +at p:networking/xrdp +snap n0 +key space \x20 +wait xrdp-on +snap n1 +at p:core/node +key space \x20 +wait node-off +snap n2 +abort +end +EOS +if drive needs 44 220 /tmp/steps.needs "$D pick"; then + selhas n1 networking/xrdp || fail "space did not tick networking/xrdp" + for k in desktop/xfce4 desktop/lightdm; do + selhas n0 "$k" && fail "$k was already on before the keystroke" + selhas n1 "$k" || fail "ticking xrdp did not pull in $k" + done + ok "forward — one space on xrdp ticked the whole desktop group" + [ "$(markof n0 g:desktop)" = "." ] || fail "g:desktop did not start empty" + [ "$(markof n1 g:desktop)" = "x" ] || fail "g:desktop reads '$(markof n1 g:desktop)' after the keystroke, not 'x'" + ok "and the count moved on that same keystroke: g:desktop . -> x" + + dropped=$(seldiff n1 n2 | tr '\n' ' ') + for k in core/node agents/codex agents/pi core/mermaid-cli core/neovim; do + selhas n2 "$k" && fail "unticking core/node did not drop $k" + done + ok "reverse — one space on core/node dropped node, codex, pi, mermaid-cli, neovim" + case " $dropped " in + *" agents/pi-plugins "*) + note "it also drops agents/pi-plugins, which the README's list omits. + Dropped set: $dropped" ;; + esac + [ "$(markof n2 g:agents)" = "~" ] || fail "g:agents reads '$(markof n2 g:agents)' after node went off, not '~'" + [ "$(markof n2 g:core)" = "~" ] || fail "g:core reads '$(markof n2 g:core)' after node went off, not '~'" + ok "both group counts moved on that same keystroke: g:agents x -> ~, g:core x -> ~" +fi + +# =========================================================== PROMISE 5 ======= +# safe pre-ticked, invasive never, gui only with a display, private later. +head_ "promise 5 — the risk model as invariants" +fresh +cat > /tmp/steps.risk <<'EOS' +ready +key ctrl-o \x0f +wait open-all +snap risk +abort +end +EOS +if drive risk 44 220 /tmp/steps.risk "$D pick"; then + bad=0 + while IFS=" " read -r g p f rest; do + case $g in \#*|@*) continue ;; esac + [ -n "${f:-}" ] || continue + on=0; selhas risk "$g/$p" && on=1 + case $f in + safe) [ "$on" -eq 1 ] || { fail "safe row $g/$p was NOT pre-ticked"; bad=1; } ;; + invasive) [ "$on" -eq 0 ] || { fail "invasive row $g/$p WAS pre-ticked"; bad=1; } ;; + private) [ "$on" -eq 0 ] || { fail "private row $g/$p WAS pre-ticked"; bad=1; } ;; + gui) [ "$on" -eq 0 ] || { fail "gui row $g/$p was pre-ticked with no display"; bad=1; } ;; + esac + done < "$MAN" + [ "$bad" -eq 0 ] && ok "with no DISPLAY: every safe row on, every gui/invasive/private row off" + [ "$(markof risk g:gpu)" = "." ] || fail "g:gpu is pre-ticked" + [ "$(markof risk g:private)" = "." ] || fail "g:private is pre-ticked" + [ "$(markof risk g:apps)" = "." ] || fail "g:apps (gui) is pre-ticked with no display" + ok "the invasive, private and gui group rows all read '.' on first draw" +fi +# and with a display, gui comes on and nothing else moves +fresh +DISPLAY=:0 "$D" preset defaults +g_on=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="gui" {print $1"/"$2}' "$MAN" | while read -r k; do grep -qxF "$k" "$ST/selected" && echo "$k"; done | wc -l) +g_all=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="gui"' "$MAN" | wc -l) +[ "$g_on" -eq "$g_all" ] || fail "with DISPLAY=:0 only $g_on of $g_all gui rows are ticked" +i_on=$(awk -F'\t' '!/^[#@]/ && NF>=3 && ($3=="invasive"||$3=="private") {print $1"/"$2}' "$MAN" | while read -r k; do grep -qxF "$k" "$ST/selected" && echo "$k"; done | wc -l) +[ "$i_on" -eq 0 ] || fail "with DISPLAY=:0, $i_on invasive/private rows became ticked" +ok "with DISPLAY set: all $g_all gui rows on, still zero invasive/private" + +# private is scheduled, not done: ticking it must not ask for anything at +# picker time. --print resolves and prints, installs nothing. +fresh +"$D" preset none +cat > /tmp/steps.priv <<'EOS' +ready +at g:private +key space \x20 +wait private-on +snap priv +key enter \r +line plan {plan} +line confirm {install\? \[y/N\]} +key yes y\r +line privsection {private} +end +EOS +if drive priv 44 220 /tmp/steps.priv "$D --print"; then + selhas priv private/private-repo || fail "space on g:private did not tick private-repo" + ok "a private row can be ticked in the picker" + scr=$(sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/screen.priv.raw | tr -d '\r') + case $scr in + *"prompt for URL, username, password (interactive only)"*) + ok "the password is scheduled for after the install, not asked at pick time" ;; + *) fail "the private tier never announced its deferred prompt" ;; + esac + pre=$(printf '%s' "$scr" | sed -n '1,/plan/p') + case $pre in *Password*) fail "a password prompt appeared BEFORE the plan" ;; esac + ok "nothing asked for a password while the picker was up" + # private is never a package + case $scr in + *"private/private-repo"*) fail "a private row reached the install plan" ;; + *) ok "the private rows never reach the plan (private is never a package)" ;; + esac +fi +# and a stale state file that ticks invasive cannot get past --unattended +fresh +awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="invasive" {print $1"/"$2}' "$MAN" > "$ST/selected" +out=$("$D" --unattended --print install 2>&1) +case $out in +*"refusing invasive packages"*) ok "--unattended refuses an invasive selection left by a stale state file" ;; +*) fail "--unattended installed from a stale invasive selection" ;; +esac + +# =========================================================== PROMISE 6 ======= +# What the picker writes, and that a second run restores it. +head_ "promise 6 — what is written, and what a second run restores" +# Deliberately NOT $DOTUP_STATE: this is the documented default location. +unset DOTUP_STATE +DEF=$HOME/.config/dotfiles +SNAPST=$DEF +export DOTUP_SNAP_STATE=$DEF +rm -rf "$DEF" +cat > /tmp/steps.write <<'EOS' +ready +key ctrl-x \x18 +wait ctrl-x +at g:media +key space \x20 +wait media-on +key tab \t +wait tab +snap w1 +key enter \r +end +EOS +if drive write 44 220 /tmp/steps.write "$D pick"; then + [ -f "$DEF/selected" ] || fail "no $DEF/selected after the picker exited" + [ -f "$DEF/expanded" ] || fail "no $DEF/expanded after the picker exited" + ok "the picker writes ~/.config/dotfiles/{selected,expanded}" + got=$(sort "$DEF/selected" | tr '\n' ' ') + [ "$got" = "media/ffmpeg media/p7zip media/sox " ] \ + || fail "selected holds '$got', not the three media rows that were ticked" + ok "selected holds exactly what was ticked: $got" + grep -qx media "$DEF/expanded" || fail "expanded does not record the group that was opened" + ok "expanded records the open group" + mode=$(stat -c %a "$DEF/selected") + note "state files are mode $mode" +fi +cat > /tmp/steps.restore <<'EOS' +ready +snap w2 +abort +end +EOS +if drive restore 44 220 /tmp/steps.restore "$D pick"; then + [ "$(markof w2 g:media)" = "x" ] || fail "a second run did not restore the media selection (mark '$(markof w2 g:media)')" + [ "$(markof w2 g:core)" = "." ] || fail "a second run re-ticked g:core, which had been cleared" + ok "a second run comes up on the same selection" + grep -q 'p:media/' <(cut -f2 /tmp/snap.w2) || fail "a second run did not restore the open group" + ok "and with the same group still open" +fi +# the empty selection is the one a second run does not restore +rm -rf "$DEF" +cat > /tmp/steps.none <<'EOS' +ready +key ctrl-x \x18 +wait ctrl-x +key enter \r +end +EOS +if drive nonesel 44 220 /tmp/steps.none "$D pick"; then + [ -s "$DEF/selected" ] && fail "^x then enter left a non-empty selection" + if drive nonesel2 44 220 /tmp/steps.restore "$D pick"; then + if [ "$(markof w2 g:core)" = "x" ]; then + fail "a deliberate empty selection is NOT restored: the second run silently + re-applied the defaults preset, because cmd_pick tests \`[ -s \$SEL ]\` + and an empty file is indistinguishable from a missing one. Choosing + 'none' and pressing enter cannot be made to stick. (BUG-3)" + else + ok "an empty selection survives a second run" + fi + fi +fi +export DOTUP_STATE=$ST; SNAPST=$ST; export DOTUP_SNAP_STATE=$ST + +# ============================================================ HOSTILE ======== +head_ "hostile 1 — no TTY" +fresh +# This scenario shell has no controlling terminal at all (docker exec without +# -t), which is exactly a CI runner or a cron job. Nothing is redirected: the +# tty is genuinely absent, which is what fzf actually checks -- it opens +# /dev/tty directly, so a redirect of stdin would not reproduce this. +out=$("$D" pick 2>&1); rc=$? +[ "$rc" -ne 0 ] || fail "the picker claimed success with no terminal" +ok "dotup pick fails with no tty (rc=$rc)" +printf '%s\n' "$out" | sed 's/^/ | /' | head -5 +case $out in +*unattended*|*--unattended*) ok "the failure names --unattended as the way out" ;; +*) fail "with no tty the user gets fzf's raw '$(printf '%s' "$out" | head -1)' and no + mention of --unattended, which is the documented answer for a machine + with nobody at the keyboard (BUG-4)" ;; +esac +if [ -s "$ST/selected" ]; then + fail "the failed picker still wrote $(grep -c . "$ST/selected") rows into the state file — + cmd_pick applies the defaults preset BEFORE fzf runs, so a run that + never drew anything still changed what a later run will install (BUG-6)" +else + ok "a picker that never drew anything left the state file alone" +fi + +head_ "hostile 2 — a 10x40 terminal" +fresh +cat > /tmp/steps.tiny <<'EOS' +ready +snap t1 +key space \x20 +wait space +snap t2 +key enter \r +end +EOS +if drive tiny 10 40 /tmp/steps.tiny "$D pick"; then + ok "the picker comes up on 10 rows x 40 columns ($(cat /tmp/cnt.t1) rows listed)" + [ "$(seln t1)" -ne "$(seln t2)" ] || fail "space did nothing on a tiny terminal" + ok "space still toggles there ($(seln t1) -> $(seln t2) rows ticked)" + [ "$(prc)" = "0" ] || fail "enter did not exit cleanly on a tiny terminal (rc=$(prc))" + ok "enter still accepts there" + scr=$(sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' /tmp/screen.tiny.raw | tr -d '\r') + case $scr in + *'space tick'*) note "the header is still (partly) visible at 40 columns" ;; + *) note "at 40 columns the header is off screen entirely — the only place the + keybindings are documented in the UI" ;; + esac +fi + +head_ "hostile 3 — fzf missing and unfetchable" +fresh +export XDG_CACHE_HOME=/tmp/nocache; rm -rf /tmp/nocache +# No network, simulated at the only place dotup reaches for one. Docker's +# default profile refuses `unshare -n` to an unprivileged user, so the network +# is removed from dotup's point of view instead of from the container's. PATH +# is modified BY THE TEST here; dotup is still the thing that must not modify it. +mkdir -p /tmp/nonet +printf '#!/bin/sh\nexit 6\n' > /tmp/nonet/curl; chmod +x /tmp/nonet/curl +out=$(PATH=/tmp/nonet:$PATH "$D" pick 2>&1); rc=$? +[ "$rc" -eq 2 ] || fail "an unfetchable fzf exits $rc, not 2" +ok "dotup pick exits 2 when fzf cannot be had" +printf '%s\n' "$out" | sed 's/^/ | /' +case $out in +*"numbered prompt"*) fail "the error still promises a numbered prompt that does not exist" ;; +esac +case $out in +*"dotup preset defaults && dotup install"*) ok "it names a fallback that exists" ;; +*) fail "the error names no usable way forward" ;; +esac +# and the advice has to actually work +PATH=/tmp/nonet:$PATH "$D" preset defaults 2>/dev/null || fail "the suggested 'dotup preset defaults' fails" +[ -s "$ST/selected" ] || fail "the suggested fallback selected nothing" +PATH=/tmp/nonet:$PATH "$D" --print install >/tmp/fallback.log 2>&1 \ + || fail "the suggested 'dotup install' fails: $(tail -2 /tmp/fallback.log)" +grep -q 'apt-get install' /tmp/fallback.log || fail "the fallback resolved no commands" +ok "and the fallback it names really does resolve the same $(grep -c . "$ST/selected") rows" +unset XDG_CACHE_HOME + +head_ "hostile 4 — a hand-edited state file" +fresh +printf 'core/eza\r\ncore/ripgrep\n\n core/htop\ncore/bat\ncore/bat\ng:core\nnot/a-package\n' > "$ST/selected" +printf 'core\nnosuchgroup\n' > "$ST/expanded" +cat > /tmp/steps.corrupt <<'EOS' +ready +snap c1 +at p:core/tree +key space \x20 +wait space-after-corrupt +snap c2 +abort +end +EOS +if drive corrupt 44 220 /tmp/steps.corrupt "$D pick"; then + ok "the picker survives junk in selected/expanded and draws $(cat /tmp/cnt.c1) rows" + [ "$(markof c1 p:core/ripgrep)" = "x" ] || fail "a clean line in a junk file was not honoured" + if [ "$(markof c1 p:core/eza)" = "x" ]; then + ok "the CRLF line was tolerated" + else + note "a CRLF line ('core/eza\\r') is silently ignored — the row reads unticked + and nothing says why, which is what a Windows-side edit or a pasted + file produces" + fi + [ "$(markof c1 p:core/htop)" = "." ] || note "a leading-space line was honoured" + [ "$(markof c1 p:core/bat)" = "x" ] || fail "the duplicated clean key was not honoured" + ok "a duplicated key is read once, not twice" + selhas c2 core/tree || fail "space stopped working after junk in the state file" + ok "space still toggles with junk in the file (core/tree went on)" + [ "$(seln c1)" -eq "$(seln c2)" ] && note "the first toggle silently rewrote the whole + file — sort -u collapsed the duplicate, so the line count did not move + even though a package was added" + if grep -qx 'not/a-package' "$ST/selected"; then + note "the junk lines are preserved verbatim through a toggle (sort -u rewrites + the file but never validates it); they are inert because every consumer + joins against the manifest" + fi + out=$("$D" --print plan 2>&1) + case $out in *not/a-package*) fail "a junk key reached the install plan" ;; + *) ok "no junk key reaches the plan" ;; esac +fi +# and the same file with no read permission, which is what a root-run dotup leaves +fresh +"$D" preset defaults +chmod 000 "$ST/selected" +cat > /tmp/steps.perm <<'EOS' +ready +snap perm1 +at g:media +key space \x20 +sleep 2500 +snap perm2 +abort +end +EOS +if drive perm 44 220 /tmp/steps.perm "$D pick"; then + if [ "$(markof perm1 g:core)" = "." ]; then + note "an unreadable selected file draws as 'nothing is ticked' rather than as + an error — the awk that reads it cannot tell 'empty' from 'refused'" + fi + if diff -q /tmp/snap.perm1 /tmp/snap.perm2 >/dev/null; then + fail "with an unreadable state file, space does nothing at all and says nothing: + cmd_toggle's final \`sort -u \$tmp > \$SEL\` fails, execute-silent throws + the status away, and the row never moves. The picker looks alive and is + inert. (BUG-7)" + else + ok "a toggle still lands with an unreadable state file" + fi +fi +chmod 644 "$ST/selected" 2>/dev/null || : + +head_ "hostile 5 — ^c in the middle of the picker" +fresh +"$D" preset defaults +before=$(md5sum "$ST/selected" | cut -d' ' -f1) +cat > /tmp/steps.sigint <<'EOS' +ready +at g:media +key space \x20 +key ctrlc \x03 +key ctrlc \x03 +sleep 1500 +end +EOS +if drive sigint 44 220 /tmp/steps.sigint "$D pick"; then + ok "^c ends the picker (rc=$(prc))" + [ "$(prc)" = "1" ] || note "^c leaves dotup exiting $(prc)" + after=$(md5sum "$ST/selected" | cut -d' ' -f1) + n=$(grep -c . "$ST/selected") + if [ "$n" -eq 0 ] && [ "$before" != "$after" ]; then + fail "^c during a toggle emptied the selection outright" + fi + # a valid selection is one where every line is a manifest key + junk=$(while read -r k; do + awk -F'\t' -v k="$k" 'BEGIN{f=1} !/^[#@]/ && NF>=3 && ($1"/"$2)==k {f=0} END{exit f}' "$MAN" || echo "$k" + done < "$ST/selected" | tr '\n' ' ') + [ -z "$junk" ] || fail "^c left unparseable lines in selected: $junk" + ok "selected still holds $n valid keys after ^c" + orphans=$(ls -A "$ST" | grep -c '^\.' || true) + if [ "$orphans" -gt 0 ]; then + fail "^c left $orphans temp file(s) behind in $ST: $(ls -A "$ST" | grep '^\.' | tr '\n' ' ') + cmd_toggle builds \$STATE/.sel.\$\$ and only removes it on success, and + the final write is \`sort -u \$tmp > \$SEL\` — a redirect, which truncates + \$SEL before sort has written a byte. Interrupted there, the selection is + gone and the temp file stays. cmd_expand already does this correctly + with mv. (BUG-8)" + else + ok "no temp files left behind in the state directory" + note "^c reaches fzf rather than the toggle child (execute-silent does not + hand over the terminal's foreground group), so this could not be made + to interrupt a write. The write is still not atomic: cmd_toggle ends + with \`sort -u \$tmp > \$SEL\`, a redirect that truncates \$SEL before sort + emits a byte. A SIGTERM, a full disk or a power cut there loses the + selection. cmd_expand next to it already writes tmp-then-mv. (BUG-8)" + fi +fi + +# =============================================================== result ====== +head_ "result" +if [ "$FAILS" -eq 0 ]; then + echo "PICKER PASS" +else + echo "PICKER: $FAILS failed assertion(s)" +fi +exit "$FAILS" diff --git a/.tests/lab/scenarios/30-private-tier.sh b/.tests/lab/scenarios/30-private-tier.sh new file mode 100644 index 0000000..dff7650 --- /dev/null +++ b/.tests/lab/scenarios/30-private-tier.sh @@ -0,0 +1,193 @@ +#!/bin/bash +# The private tier, end to end, against a local stand-in for everything remote. +# +# Nothing here touches the real endpoint, the real repos or the real secrets +# manager, so it can be run as often as it takes and costs nothing when it +# fails. What it does exercise is the real code: dotup's prompt loop, the real +# credential splitting, chezmoi's real seven-question TUI, and the private +# tier's real `dotsecrets` -- copied verbatim into the fake source tree, so it +# is the shipping implementation being measured, not a rewrite of it. +# +# The first password is deliberately wrong. These credentials are asked for at +# the very end of a run, so before the retry loop existed one typo meant redoing +# the entire install. Getting it wrong on purpose is the only way to prove the +# recovery path is there and that the URL and username survive the mistake. +set -u +fail() { echo "FAIL: $*"; exit 1; } +ok() { echo " ok $*"; } +umask 022 # modes below assume it; do not let the daemon's umask decide + +sudo apt-get update -qq && sudo apt-get install -y -qq git expect unzip >/dev/null 2>&1 +echo " NOTE installed git by hand -- see ISSUE-1" + +# ---- public tier first: the private tier is a continuation, never a start ---- +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \ + || { tail -5 /tmp/init.log; fail "public tier init"; } +export PATH="$HOME/.local/bin:$HOME/bin:$PATH" +D=$HOME/.local/bin/dotup +[ -x "$D" ] || fail "no dotup after the public apply" +ok "public tier applied" + +# absence assertions BEFORE the private tier exists, so their later presence +# means something +for f in "$HOME/.config/zsh/secrets.zsh" "$HOME/.config/bitwarden/bws-token" \ + "$HOME/.local/share/dotfiles-private"; do + [ ! -e "$f" ] || fail "$f exists on a public-only machine" +done +ok "public-only machine carries no token, no secrets, no private source" + +# ---- a fake bws, so nothing reaches the network ----------------------------- +# `ensure_bws` checks `have bws` first and `have` searches ~/.local/bin, so +# putting the stub there is enough to keep the real download out of this run. +# The real ensure_bws (pin, checksum, musl target) is a separate scenario -- +# faking it here would only prove the fake works. +mkdir -p "$HOME/.local/bin" +cat > "$HOME/.local/bin/bws" <<'BWS' +#!/bin/sh +# Stand-in for the Bitwarden Secrets Manager CLI. Answers exactly the call +# dotsecrets makes: `bws secret get -o env`, printing KEY=VALUE. +# LAB_BWS_MODE bends it to drive the failure branches. +case "${LAB_BWS_MODE:-ok}" in + fail) exit 1 ;; + wrong) printf 'SOMETHING_ELSE=x\n'; exit 0 ;; + empty) printf 'LAB_ALPHA_API_KEY=\n'; exit 0 ;; +esac +[ "$1" = secret ] && [ "$2" = get ] || { echo "unsupported: $*" >&2; exit 2; } +case "$3" in +*0001) k=LAB_ALPHA_API_KEY ;; *0002) k=LAB_BRAVO_API_KEY ;; +*0003) k=LAB_CHARLIE_API_KEY ;; *0004) k=LAB_DELTA_API_KEY ;; +*0005) k=LAB_ECHO_API_KEY ;; *0006) k=LAB_FOXTROT_API_KEY ;; +*0007) k=LAB_GOLF_API_KEY ;; *) echo "unknown id $3" >&2; exit 1 ;; +esac +printf '%s=lab-value-for-%s\n' "$k" "$k" +BWS +chmod 755 "$HOME/.local/bin/bws" +ok "fake bws in place; this run reaches no network" + +# ---- tick the private rows, as the picker would ------------------------------ +S=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles +mkdir -p "$S"; printf 'private/private-repo\nprivate/bws-secrets\n' > "$S/selected" + +# ---- drive it exactly as a person sitting at the keyboard would -------------- +export E2E_NAME='Lab Tester' E2E_EMAIL='lab@example.invalid' +timeout 420 expect -f - >/tmp/private.log 2>&1 <<'EXP' +set timeout 120 +log_user 1 +spawn -noecho env PATH=$env(PATH) $env(HOME)/.local/bin/dotup private + +# A bare `expect -re {pat} {}` treats a timeout as "carry on", so a missed +# prompt falls through silently and every later expect waits out its own +# timeout, surfacing much later as an unattributed hang. Name what was missed. +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 prompts are plain `read` in cooked mode, 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 -- "wrong-on-purpose\r" + +wait_for {wrong username or password} "the 401 message naming the actual fault" +# The bracketed default is the proof that the URL and username were retained, +# so only the password has to be retyped. +wait_for {Bootstrap URL \[} "the retry prompt with the URL kept" +send -- "\r" +wait_for {Username \[} "the retry prompt with the username kept" +send -- "\r" +wait_for {Password:} "the retry password prompt" +send -- "$env(BOOT_PW)\r" + +# chezmoi's seven are a full-screen TUI, and matching the prompt TEXT is not +# enough. chezmoi writes the prompt while the tty is still in cooked mode and +# only then switches to raw with TCSAFLUSH, which DISCARDS anything already +# buffered. An answer sent on the text alone can land in that window and be +# thrown away -- the field sits unsubmitted and the run burns its whole +# timeout. `\033[?2004h` is bracketed-paste-on, emitted only after raw mode is +# established, so waiting for it turns "probably ready" into "demonstrably +# ready". Each prompt emits its own. +proc ask {pat val} { + wait_for $pat "chezmoi prompt $pat" + wait_for "\033\\\[\\?2004h" "raw mode after $pat (the TUI never became ready)" + send -- "$val\r" +} +ask {user\.name} "$env(E2E_NAME)" +ask {user\.email} "$env(E2E_EMAIL)" +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=$? +red() { sed -e "s|$BOOT_PW||g" -e "s|$BOOT_URL||g"; } +case $rc in + 124) red /dev/null; } + +[ -d "$HOME/.local/share/dotfiles-private" ] || fail "private source not cloned" +case $(m "$HOME/.local/share/dotfiles-private") in *00) ;; *) + fail "private source is mode $(m "$HOME/.local/share/dotfiles-private") — group/other can read it" ;; esac +ok "private source cloned, go-rwx" + +[ "$(m "$HOME/.config/bitwarden/bws-token")" = 600 ] || fail "bws token mode $(m "$HOME/.config/bitwarden/bws-token"), want 600" +ok "bws token written, mode 600" + +SEC=$HOME/.config/zsh/secrets.zsh +[ -r "$SEC" ] || fail "secrets.zsh not generated" +[ "$(m "$SEC")" = 600 ] || fail "secrets.zsh mode $(m "$SEC"), want 600" +n=$(grep -c '^export ' "$SEC") +[ "$n" -eq 8 ] || fail "secrets.zsh has $n exports, want 8 (7 secrets + 1 alias)" +grep -q '^export LAB_GOLF_ALIAS_KEY=' "$SEC" || fail "the alias export is missing" +ok "secrets.zsh mode 600, 8 exports, alias present" + +for p in "$HOME/.config/zsh:700" "$HOME/.ssh:700" "$HOME/.ssh/config:600" \ + "$HOME/.config/zsh/local.zsh:600" "$HOME/.local/bin/dotsecrets:700"; do + want=${p##*:}; path=${p%:*} + [ "$(m "$path")" = "$want" ] || fail "$path is mode $(m "$path"), want $want" +done +ok "every private destination carries the mode it claims" + +# The credential must survive as a file and nowhere else. +CRED=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/private-credentials +[ "$(m "$CRED")" = 600 ] || fail "credential file mode $(m "$CRED"), want 600" +GC=$HOME/.local/share/dotfiles-private/.git/config +grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' "$GC" \ + && fail "the clone's .git/config still carries the token" +grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' /tmp/private.log \ + && fail "a credential-bearing URL appears in dotup's own output" +ok "token is in the 600-mode credential file only — not in .git/config, not in the log" + +git -C "$HOME/.local/share/dotfiles-private" fetch -q 2>/dev/null \ + || fail "a later fetch cannot authenticate — the credential helper did not survive" +ok "a later fetch still authenticates from that file" + +# ---- the failure branches: yesterday's keys beat no keys -------------------- +before=$(sha256sum "$SEC" | cut -d' ' -f1) +for mode in fail wrong empty; do + LAB_BWS_MODE=$mode "$HOME/.local/bin/dotsecrets" >/dev/null 2>&1 + [ "$(sha256sum "$SEC" | cut -d' ' -f1)" = "$before" ] \ + || fail "bws mode '$mode' modified secrets.zsh — it must be left alone on failure" +done +ls "$HOME/.config/zsh"/.secrets.zsh.* >/dev/null 2>&1 && fail "a temp file survived a failed refresh" +ok "a failed refresh leaves secrets.zsh byte-identical and no temp file behind" + +echo "PRIVATE TIER PASS" diff --git a/.tests/lab/scenarios/31-chezmoi-absent.sh b/.tests/lab/scenarios/31-chezmoi-absent.sh new file mode 100755 index 0000000..c6219be --- /dev/null +++ b/.tests/lab/scenarios/31-chezmoi-absent.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# chezmoi is how dotup ARRIVES, so "it must already be here" is the natural +# assumption -- and it is wrong often enough to have broken a real install. +# +# get.chezmoi.io installs to ./bin RELATIVE TO THE CWD when -b is not given, +# which is exactly what the README's one-liner does. Run it from $HOME and the +# binary lands in ~/bin; run it from /workspace, as anyone in a container does, +# and it lands in /workspace/bin. Neither is on PATH, and the private tier then +# failed with `chezmoi: not found` AFTER writing the bws token -- half +# configured, at the very last step, having already spent the password. +# +# So this scenario puts the machine in exactly that state -- chezmoi nowhere +# find_tool looks -- and asks two questions: +# +# does dotup install one, before asking for anything? +# and if it CANNOT, does it say so before the password rather than after? +# +# It cannot be a unit test on a developer box: find_tool probes +# /home/linuxbrew/.linuxbrew/bin by absolute path, and any box with linuxbrew +# satisfies the lookup no matter what PATH says. A container has no such +# directory, so absence here is real. +set -u +fail() { echo "FAIL: $*"; exit 1; } +ok() { echo " ok $*"; } + +# ---- public tier first, unaided, exactly as the README says ------------------ +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \ + || { tail -20 /tmp/init.log; fail "public tier init"; } +D=$HOME/.local/bin/dotup +[ -x "$D" ] || fail "no dotup after the public apply" +ok "public tier applied" + +# ---- now take chezmoi away, everywhere find_tool looks ---------------------- +for c in "$HOME/.local/bin/chezmoi" "$HOME/bin/chezmoi" "$HOME/.npm-global/bin/chezmoi" \ + /usr/local/bin/chezmoi /usr/local/go/bin/chezmoi \ + /home/linuxbrew/.linuxbrew/bin/chezmoi /opt/homebrew/bin/chezmoi; do + [ -e "$c" ] && { sudo rm -f "$c" || rm -f "$c"; } +done +command -v chezmoi >/dev/null 2>&1 && fail "chezmoi is still on PATH; the state under test never happened" +for c in "$HOME/.local/bin/chezmoi" "$HOME/bin/chezmoi" "$HOME/.npm-global/bin/chezmoi" \ + /usr/local/bin/chezmoi /usr/local/go/bin/chezmoi \ + /home/linuxbrew/.linuxbrew/bin/chezmoi /opt/homebrew/bin/chezmoi; do + [ -e "$c" ] && fail "chezmoi is still at $c -- find_tool would find it" +done +ok "chezmoi is absent from every place find_tool looks" + +# ---- tick the row that needs it, and answer q at the first prompt ------------ +# `q` is enough: ensure_chezmoi runs BEFORE the prompt on purpose, so whatever +# it did has already happened by the time the first question is asked. +S=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles +mkdir -p "$S"; printf 'private/private-repo\n' > "$S/selected" +printf 'q\n' | timeout 180 script -q -c "$D private" /dev/null >/tmp/priv.log 2>&1 +rc=$? +out=$(tr -d '\r' /dev/null 2>&1 || fail "the installed chezmoi does not run" +ok "the installed chezmoi is a working binary" + +# The ordering that makes the failure survivable: chezmoi is resolved BEFORE +# anything is asked for. If it can only be discovered missing afterwards, the +# password has been spent and the token is already on disk. +a=$(printf '%s\n' "$out" | grep -n 'installing it' | head -1 | cut -d: -f1) +b=$(printf '%s\n' "$out" | grep -n 'Bootstrap URL' | head -1 | cut -d: -f1) +[ -n "$a" ] && [ -n "$b" ] && [ "$a" -lt "$b" ] \ + || fail "chezmoi was not resolved before the first prompt (installing=$a prompt=$b)" +ok "chezmoi was resolved before the first question was asked" + +case $out in +*"public-only machine"*) ok "q at the prompt left a public-only machine" ;; +*) fail "q was not accepted at the URL prompt" ;; +esac +echo "CHEZMOI-ABSENT PASS" diff --git a/.tests/lab/serve.py b/.tests/lab/serve.py new file mode 100755 index 0000000..d042039 --- /dev/null +++ b/.tests/lab/serve.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Local stand-in for the whole remote side of a bootstrap. + +One process serves three things a real machine reaches out to: + + GET //bootstrap.env basic auth, returns the two-line blob + GET /git/dotfiles-public.git anonymous, smart git-http + GET /git/dotfiles-private.git basic auth, smart git-http + anything else connection closed with no response + +The last one is not laziness. Caddy's catch-all is `handle { abort }`, which +closes the connection without a status line, so a retired route reports curl +exit 52/000 rather than 404. A mock that answered 404 there would let a wrong +assertion pass. + +Serving git over HTTP rather than `git daemon` is deliberate too: the private +clone URL carries an inline token, and the code under test splits that token +out into a credential file. Over git:// there is no credential to split and +that entire path goes untested. + +No credential is ever logged. Bodies are streamed, so packfiles of any size +work without buffering. +""" +import base64 +import os +import socket +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +ROUTE = os.environ.get("LAB_ROUTE", "bootstrap") +USER = os.environ.get("LAB_USER", "ben") +PASS = os.environ.get("LAB_PASS", "lab-password") +GIT_ROOT = os.environ["LAB_GIT_ROOT"] +# The blob names the git URL, which names the port -- but the port is not +# known until bind() returns. Rather than start the server twice, let it +# substitute its own port: LAB_BLOB may contain {PORT}. +BLOB = os.environ["LAB_BLOB"] +GIT_TOKEN = os.environ.get("LAB_GIT_TOKEN", "lab-git-token") +# 0 means "any free port". Several scenarios run in parallel, so a fixed port +# turns a second run into "Address already in use" -- which reads like a lab +# fault rather than a scheduling one. The chosen port is printed for the +# caller to read back. +PORT = int(os.environ.get("LAB_PORT", "0")) +# Default to loopback. The driver overrides this with the docker0 address so +# containers can reach it; that interface is not routable off the box, which +# 0.0.0.0 would have been. A repo that is private by construction should not +# be reachable from the LAN because a test was running. +BIND = os.environ.get("LAB_BIND", "127.0.0.1") + +BACKEND = "/usr/lib/git-core/git-http-backend" + + +def _check(header, user, pw): + if not header or not header.startswith("Basic "): + return False + try: + raw = base64.b64decode(header[6:]).decode("utf-8", "replace") + except Exception: + return False + got_u, _, got_p = raw.partition(":") + return got_u == user and got_p == pw + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "lab/1.0" + + # The default logger writes the request line to stderr. Request lines here + # contain no credential (basic auth rides in a header), but the endpoint + # route is meant to be unguessable, so it stays out of the log too. + def log_message(self, fmt, *a): + pass + + def _abort(self): + """Close with no response at all -- what `handle { abort }` does. + + BaseHTTPRequestHandler flushes wfile after handle_one_request returns, + so simply closing it raises out of the server's own plumbing and prints + a traceback that looks like a lab fault rather than the behaviour under + test. Point wfile at /dev/null first: the socket is genuinely gone, and + the inherited flush lands somewhere harmless. + """ + self.close_connection = True + try: + self.connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self.connection.close() + finally: + self.wfile = open(os.devnull, "wb") + + def _unauth(self, realm): + body = b"unauthorized\n" + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="%s"' % realm) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + self._route("GET") + + def do_POST(self): + self._route("POST") + + def _route(self, method): + path = self.path.split("?", 1)[0] + auth = self.headers.get("Authorization") + + if path == "/%s/bootstrap.env" % ROUTE: + if not _check(auth, USER, PASS): + return self._unauth("bootstrap") + body = BLOB.encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + return self.wfile.write(body) + + if path.startswith("/git/"): + # The private repo demands the token; the public one is open, + # because a stranger really can clone the public tier. + if "dotfiles-private" in path and not _check(auth, "git", GIT_TOKEN): + return self._unauth("git") + return self._git(method, path) + + self._abort() + + def _git(self, method, path): + env = { + "GIT_PROJECT_ROOT": GIT_ROOT, + "GIT_HTTP_EXPORT_ALL": "1", + "PATH_INFO": path[len("/git"):], + "REQUEST_METHOD": method, + "QUERY_STRING": self.path.split("?", 1)[1] if "?" in self.path else "", + "REMOTE_ADDR": self.client_address[0], + "REMOTE_USER": "lab", + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + } + for h, e in (("Content-Type", "CONTENT_TYPE"), + ("Content-Encoding", "HTTP_CONTENT_ENCODING"), + ("Accept-Encoding", "HTTP_ACCEPT_ENCODING"), + ("Git-Protocol", "HTTP_GIT_PROTOCOL")): + v = self.headers.get(h) + if v: + env[e] = v + n = int(self.headers.get("Content-Length") or 0) + if n: + env["CONTENT_LENGTH"] = str(n) + + p = subprocess.Popen([BACKEND], env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + if n: + p.stdin.write(self.rfile.read(n)) + p.stdin.close() + + # CGI response: headers, blank line, body. Status comes back as a + # `Status:` header when it is not 200. + head, status, sent = [], 200, [] + while True: + line = p.stdout.readline() + if not line or line in (b"\r\n", b"\n"): + break + k, _, v = line.decode("latin-1").rstrip("\r\n").partition(":") + if k.lower() == "status": + status = int(v.strip().split()[0]) + else: + head.append((k, v.strip())) + + self.send_response(status) + for k, v in head: + self.send_header(k, v) + # Length is unknown up front for a packfile, so stream it chunked. + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + while True: + chunk = p.stdout.read(65536) + if not chunk: + break + self.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n") + self.wfile.write(b"0\r\n\r\n") + p.stdout.close() + p.wait() + + +def _main(): + global BLOB + if not os.path.exists(BACKEND): + sys.exit("git-http-backend not found at %s" % BACKEND) + srv = ThreadingHTTPServer((BIND, PORT), Handler) + srv.daemon_threads = True + BLOB = BLOB.replace("{PORT}", str(srv.server_address[1])) + print("lab: listening on %s:%d, route /%s, git root %s" + % (BIND, srv.server_address[1], ROUTE, GIT_ROOT), flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + _main() diff --git a/.tests/lab/snapshot.sh b/.tests/lab/snapshot.sh new file mode 100755 index 0000000..eb1e48e --- /dev/null +++ b/.tests/lab/snapshot.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Turn a WORKING TREE into a bare repo the lab server can hand out. +# +# `git clone` of a checkout only ever produces committed refs, so testing a +# change used to mean pushing it first -- which is backwards, and is the reason +# every bug in this repo was found by a human on a real machine instead of by +# a test. Copying the tree and committing it into a throwaway repo means the +# code under test is whatever is on disk right now, staged or not. +# +# usage: snapshot.sh +set -eu +src=$1; root=$2; name=$3 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# --delete is pointless into a fresh mktemp dir, but it documents intent if +# this is ever pointed at a reused path. +tar -C "$src" --exclude=.git -cf - . | tar -C "$work" -xf - + +git -C "$work" init -q -b main +git -C "$work" add -A +git -C "$work" \ + -c user.email=lab@example.invalid -c user.name=lab \ + commit -qm "working-tree snapshot of $name" + +rm -rf "$root/$name.git" +mkdir -p "$root" +git clone -q --bare "$work" "$root/$name.git" +git -C "$root/$name.git" update-server-info +printf '%s\n' "$root/$name.git"