From a001406a330fa6cd13c6e10ec73c5230376c1880 Mon Sep 17 00:00:00 2001 From: bcherb2 Date: Fri, 21 Aug 2026 22:34:53 -0400 Subject: [PATCH] test: container lab for the two-tier apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disposable ubuntu container, a fake private tier and a fake bootstrap endpoint, so the whole documented path — chezmoi init --apply, dotup pick, dotup private, cmp apply, dotsecrets — can run end to end without touching a real machine or a real credential. The fake tier mirrors the real one's structure (seven secrets and one alias) because dotsecrets is copied verbatim and the "8 exports, not 7" assertion depends on that cardinality; its ids are sequential and obviously synthetic. check-verbatim.sh keeps the fake tier's copies of shipped files honest, and snapshot.sh records file modes so a 644 where a 600 belongs is a diff. --- .tests/lab/Dockerfile | 42 + .tests/lab/check-verbatim.sh | 22 + .tests/lab/fake-private/.chezmoi.toml.tmpl | 36 + .tests/lab/fake-private/.chezmoidata/bws.toml | 43 + .tests/lab/fake-private/.chezmoiignore | 91 ++ .../lab/fake-private/dot_claude/settings.json | 1 + .../bin/private_executable_dotsecrets.tmpl | 213 ++++ .../private_dot_config/git/config.local.tmpl | 71 ++ .../private_zsh/private_local.zsh.tmpl | 61 + .../private_dot_ssh/private_authorized_keys | 1 + .../private_dot_ssh/private_config.tmpl | 57 + .../private_dot_ssh/pubkeys/dev.pub | 1 + .../fake-private/run_after_50-secrets.sh.tmpl | 44 + .tests/lab/run.sh | 114 ++ .tests/lab/scenarios/00-smoke.sh | 62 + .tests/lab/scenarios/10-manifest-install.sh | 310 +++++ .tests/lab/scenarios/11-brew.sh | 64 + .tests/lab/scenarios/20-picker.sh | 1056 +++++++++++++++++ .tests/lab/scenarios/30-private-tier.sh | 193 +++ .tests/lab/scenarios/31-chezmoi-absent.sh | 94 ++ .tests/lab/serve.py | 203 ++++ .tests/lab/snapshot.sh | 30 + 22 files changed, 2809 insertions(+) create mode 100644 .tests/lab/Dockerfile create mode 100755 .tests/lab/check-verbatim.sh create mode 100644 .tests/lab/fake-private/.chezmoi.toml.tmpl create mode 100644 .tests/lab/fake-private/.chezmoidata/bws.toml create mode 100644 .tests/lab/fake-private/.chezmoiignore create mode 100644 .tests/lab/fake-private/dot_claude/settings.json create mode 100644 .tests/lab/fake-private/dot_local/bin/private_executable_dotsecrets.tmpl create mode 100644 .tests/lab/fake-private/private_dot_config/git/config.local.tmpl create mode 100644 .tests/lab/fake-private/private_dot_config/private_zsh/private_local.zsh.tmpl create mode 100644 .tests/lab/fake-private/private_dot_ssh/private_authorized_keys create mode 100644 .tests/lab/fake-private/private_dot_ssh/private_config.tmpl create mode 100644 .tests/lab/fake-private/private_dot_ssh/pubkeys/dev.pub create mode 100644 .tests/lab/fake-private/run_after_50-secrets.sh.tmpl create mode 100755 .tests/lab/run.sh create mode 100644 .tests/lab/scenarios/00-smoke.sh create mode 100644 .tests/lab/scenarios/10-manifest-install.sh create mode 100644 .tests/lab/scenarios/11-brew.sh create mode 100644 .tests/lab/scenarios/20-picker.sh create mode 100644 .tests/lab/scenarios/30-private-tier.sh create mode 100755 .tests/lab/scenarios/31-chezmoi-absent.sh create mode 100755 .tests/lab/serve.py create mode 100755 .tests/lab/snapshot.sh 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"