Files
dotfiles-public/.tests/lab/fake-private/dot_local/bin/private_executable_dotsecrets.tmpl
T
bcherb2 a001406a33 test: container lab for the two-tier apply
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.
2026-08-21 22:34:53 -04:00

214 lines
7.8 KiB
Cheetah

#!/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