diff --git a/src/dotfiles-sync/README.md b/src/dotfiles-sync/README.md index 0f7d617..4836daf 100644 --- a/src/dotfiles-sync/README.md +++ b/src/dotfiles-sync/README.md @@ -102,6 +102,15 @@ That's it. The feature auto-detects the environment and adapts its behavior. | `.gnupg` | Copied on local/WSL; **skipped on cloud environments** (see below) | | All other files (git/ignore, git/attributes, yarnrc.yml, …) | **Copy-if-absent** — never overwrites an existing target | +### Host-path rewriting and verification + +The host's `.gitconfig` can reference files by absolute path (e.g. `user.signingkey` for SSH-based commit signing). Those paths are meaningless inside the container — the host's home directory isn't mounted, only specific dotfiles are. Two safeguards handle this: + +- **Rewrite**: for a small set of keys known to hold a bare filesystem path (`user.signingkey`, `http.sslCert`, `http.sslKey`, `http.sslCAInfo`), if the value points inside `.ssh/` or `.gnupg/`, it's rewritten to `TARGET_HOME/.ssh/` or `TARGET_HOME/.gnupg/` — matching where the SSH/GPG sync steps actually re-home those files (subdirectories under `.gnupg/` are preserved, since that sync is recursive). +- **Verify**: after the `.ssh`/`.gnupg` syncs have actually run (not before — checking earlier would flag a file the rewrite just pointed at correctly as "missing", since it hadn't been copied yet), the same keys plus `gpg.program`, `core.editor`, and `credential.helper` (paths the rewrite can't fix, since they point at host binaries/scripts with no deterministic container equivalent — e.g. a macOS Homebrew prefix, or a custom credential helper script) are checked for existence. A leading `!` (credential.helper's shell-invocation prefix) and trailing arguments (e.g. `code --wait`) are stripped before checking. A `WARN` is printed for anything missing — sync never fails, but you get a visible signal instead of a commit silently failing to sign weeks later. + +Note: `git config --list` always lowercases the key portion of a name (`http.sslCert` becomes `http.sslcert`), so the internal allowlists this relies on are matched in lowercase — this is transparent to you as a user, but matters if you're reading the script's source. + ### Cloud environment protection On **GitHub Codespaces**, **Gitpod**, and **DevPod**, the platform manages git authentication and GPG signing. The following `.gitconfig` keys are **never overwritten** if already set by the platform: diff --git a/src/dotfiles-sync/sync-files.sh b/src/dotfiles-sync/sync-files.sh index b059dab..8bad35b 100755 --- a/src/dotfiles-sync/sync-files.sh +++ b/src/dotfiles-sync/sync-files.sh @@ -12,6 +12,13 @@ # .gitconfig -> merge via `git config`: source keys applied only when absent # in target; protected keys (credential.helper, user.*, gpg.*) # never overwritten on cloud environments (managed by platform). +# Bare-path values (user.signingkey, http.ssl*) that point +# inside the synced .ssh/.gnupg dirs are rewritten to the +# container's TARGET_HOME. After the .ssh/.gnupg syncs below +# have run, path-like keys (signingkey, gpg.program, +# core.editor, credential.helper, http.ssl*) are checked for +# existence in the container and a WARN is printed (not +# fatal) if a host-specific path didn't survive. # .npmrc -> merge line-by-line (key=value): source entries appended only # when the key is absent from the target. # .ssh/config -> merge Host blocks: source blocks appended when Host absent. @@ -103,6 +110,16 @@ _gitconfig_get() { git config --file "$1" --get "$2" 2>/dev/null || true } +# Membership test for a space-separated allowlist string (e.g. PROTECTED_KEYS, +# REHOMEABLE_PATH_KEYS, VERIFY_PATH_KEYS). Args: +_key_in_list() { + local _needle="$1" _item + for _item in ${2}; do + [ "${_needle}" = "${_item}" ] && return 0 + done + return 1 +} + # ── Merge .gitconfig ────────────────────────────────────────────────────────── if [ -L "${SOURCE_HOME}/.gitconfig" ]; then @@ -116,6 +133,28 @@ elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; t # Smart merge via git config PROTECTED_KEYS="credential.helper user.name user.email user.signingkey gpg.program gpg.format commit.gpgsign tag.gpgsign" + # Keys whose value is a bare filesystem path (never a shell command + # string) that may point inside the synced .ssh/.gnupg directories — + # e.g. user.signingkey with an SSH key under the host user's home. + # The host home doesn't exist in the container, but the referenced + # file itself is re-homed under TARGET_HOME/.ssh or TARGET_HOME/.gnupg + # by the syncs below, so rewrite the value to match. This is what + # broke commit signing before: the path survived the merge verbatim. + # + # NOTE: `git config --list` always lowercases the key portion (e.g. + # `http.sslCert` -> `http.sslcert`), so every entry here MUST already + # be lowercase or the membership check below silently never matches. + REHOMEABLE_PATH_KEYS="user.signingkey http.sslcert http.sslkey http.sslcainfo" + + # Keys worth a post-sync existence check even when no deterministic + # target path is known (gpg.program/core.editor/credential.helper + # point at a host binary or script — there's no container equivalent + # to rewrite them to). Includes every REHOMEABLE_PATH_KEYS entry too, + # so the two lists can't silently drift apart — see the verify pass + # after the .ssh/.gnupg syncs below. + VERIFY_ONLY_PATH_KEYS="gpg.program core.editor credential.helper" + VERIFY_PATH_KEYS="${REHOMEABLE_PATH_KEYS} ${VERIFY_ONLY_PATH_KEYS}" + MERGED=0 SKIPPED=0 while IFS= read -r line; do @@ -123,20 +162,26 @@ elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; t VAL="${line#*=}" [ -z "${KEY}" ] && continue + if _key_in_list "${KEY}" "${REHOMEABLE_PATH_KEYS}"; then + case "${VAL}" in + */.ssh/*) + VAL="${TARGET_HOME}/.ssh/${VAL#*/.ssh/}" + ;; + */.gnupg/*) + VAL="${TARGET_HOME}/.gnupg/${VAL#*/.gnupg/}" + ;; + esac + fi + # On cloud envs: skip protected keys if already present if [ "${IS_CLOUD_ENV}" = "true" ]; then - _protected=false - for pkey in ${PROTECTED_KEYS}; do - if [ "${KEY}" = "${pkey}" ]; then - existing="$(_gitconfig_get "${TARGET_GIT}" "${KEY}")" - if [ -n "${existing}" ]; then - _protected=true - SKIPPED=$((SKIPPED + 1)) - fi - break + if _key_in_list "${KEY}" "${PROTECTED_KEYS}"; then + existing="$(_gitconfig_get "${TARGET_GIT}" "${KEY}")" + if [ -n "${existing}" ]; then + SKIPPED=$((SKIPPED + 1)) + continue fi - done - [ "${_protected}" = "true" ] && continue + fi fi # Merge: only set if not already present @@ -292,6 +337,33 @@ else echo " .gnupg: not found in staging" fi +# ── Verify .gitconfig path-like values ───────────────────────────────────────── +# Runs only now — after the .ssh/.gnupg syncs above have actually copied any +# rehomed files into place. Running this earlier (right after the .gitconfig +# merge) flagged a freshly-rewritten user.signingkey as missing on every first +# sync, because the key file hadn't been copied into TARGET_HOME/.ssh yet. +# Best-effort: warn, never fail. +if [ "${HAS_GIT}" = "true" ] && [ -n "${TARGET_GIT:-}" ] && [ -f "${TARGET_GIT}" ]; then + while IFS= read -r line; do + vkey="${line%%=*}" + vval="${line#*=}" + [ -z "${vkey}" ] && continue + _key_in_list "${vkey}" "${VERIFY_PATH_KEYS}" || continue + + # credential.helper may use a leading "!" to mean "run this as a + # shell command"; gpg.program/core.editor may carry trailing flags + # (e.g. `code --wait`). Only the leading token needs to resolve to a + # file — strip both before checking existence. + _check="${vval#!}" + _check="${_check%% *}" + case "${_check}" in + /*) + [ -e "${_check}" ] || echo " WARN: ${vkey}=${vval} does not exist in container (host-specific path?)" + ;; + esac + done < <(git config --file "${TARGET_GIT}" --list 2>/dev/null) +fi + # ── Helper: copy-if-absent ──────────────────────────────────────────────────── # Copies a single source file to target only if target does not already exist. # Skips symlinks (security), empty files, and missing sources. diff --git a/test/dotfiles-sync/test.sh b/test/dotfiles-sync/test.sh index 2f03ace..09aecaf 100755 --- a/test/dotfiles-sync/test.sh +++ b/test/dotfiles-sync/test.sh @@ -165,6 +165,142 @@ else fi rm -rf "${TMP_SRC}" "${TMP_DST}" +# Test 5h: bare-path git config values under .ssh/.gnupg are rewritten to +# TARGET_HOME/.ssh or TARGET_HOME/.gnupg for allowlisted keys only (mirrors +# REHOMEABLE_PATH_KEYS + the _key_in_list rewrite in sync-files.sh). Routed +# through a REAL `git config --file ... --list` round trip (not a hand-typed +# key string) — `git config --list` always lowercases keys, so a previous +# version of this test that called the rewrite helper directly with the +# mixed-case key spelling ("http.sslKey") never exercised that normalization +# and missed a real bug where the allowlist itself used mixed-case spellings +# that could never match the lowercased KEY seen in production. +REHOMEABLE_PATH_KEYS="user.signingkey http.sslcert http.sslkey http.sslcainfo" +TEST_TARGET_HOME="/home/other-user" + +_key_in_list_test() { + local _needle="$1" _item + for _item in ${2}; do + [ "${_needle}" = "${_item}" ] && return 0 + done + return 1 +} + +TMP_SRC_GIT=$(mktemp) +git config --file "${TMP_SRC_GIT}" user.signingKey "/home/some-host-user/.ssh/id_test_ed25519.pub" +git config --file "${TMP_SRC_GIT}" http.sslCert "/home/some-host-user/.gnupg/nested/client.key" +git config --file "${TMP_SRC_GIT}" core.editor "/home/some-host-user/.ssh/some-editor" + +REWRITTEN="" +while IFS= read -r line; do + _key="${line%%=*}" + _val="${line#*=}" + if _key_in_list_test "${_key}" "${REHOMEABLE_PATH_KEYS}"; then + case "${_val}" in + */.ssh/*) + _val="${TEST_TARGET_HOME}/.ssh/${_val#*/.ssh/}" + ;; + */.gnupg/*) + _val="${TEST_TARGET_HOME}/.gnupg/${_val#*/.gnupg/}" + ;; + esac + fi + REWRITTEN="${REWRITTEN}${_key}=${_val} +" +done < <(git config --file "${TMP_SRC_GIT}" --list) +rm -f "${TMP_SRC_GIT}" + +if echo "${REWRITTEN}" | grep -qF "user.signingkey=/home/other-user/.ssh/id_test_ed25519.pub"; then + echo "PASS: user.signingkey .ssh path rewritten to TARGET_HOME/.ssh" +else + echo "FAIL: user.signingkey path not rewritten correctly" + echo "${REWRITTEN}" + exit 1 +fi + +# This is the exact regression this test previously missed: git normalizes +# "http.sslCert" to "http.sslcert" in --list output, and the rewrite must +# also preserve the subdirectory under .gnupg (not just the basename), since +# the real .gnupg sync copies files recursively. +if echo "${REWRITTEN}" | grep -qF "http.sslcert=/home/other-user/.gnupg/nested/client.key"; then + echo "PASS: http.sslCert (normalized to http.sslcert) .gnupg nested path rewritten, subdirectory preserved" +else + echo "FAIL: http.sslCert path not rewritten correctly (mixed-case key or nested-path regression)" + echo "${REWRITTEN}" + exit 1 +fi + +if echo "${REWRITTEN}" | grep -qF "core.editor=/home/some-host-user/.ssh/some-editor"; then + echo "PASS: non-allowlisted key left untouched by the rehome rewrite" +else + echo "FAIL: rehome rewrite touched a key outside REHOMEABLE_PATH_KEYS" + echo "${REWRITTEN}" + exit 1 +fi + +# Test 5i: post-sync verification (single `git config --list` pass, run only +# after .ssh/.gnupg are synced — see sync-files.sh) warns for path-like +# values missing in the container, strips a leading "!" (credential.helper's +# shell-invocation prefix) and trailing arguments (e.g. `code --wait`) before +# checking, and stays silent for values that exist or aren't path-shaped. +VERIFY_ONLY_PATH_KEYS="gpg.program core.editor credential.helper" +VERIFY_PATH_KEYS="${REHOMEABLE_PATH_KEYS} ${VERIFY_ONLY_PATH_KEYS}" + +TMP_GIT=$(mktemp) +EXISTING_FILE=$(mktemp) +git config --file "${TMP_GIT}" user.signingkey "/definitely/does/not/exist/id_ed25519.pub" +git config --file "${TMP_GIT}" gpg.program "${EXISTING_FILE} --batch" +git config --file "${TMP_GIT}" core.editor "code --wait" +git config --file "${TMP_GIT}" credential.helper "!/definitely/does/not/exist/git-credential-wrapper --flag" + +VERIFY_OUTPUT=$( + while IFS= read -r line; do + vkey="${line%%=*}" + vval="${line#*=}" + [ -z "${vkey}" ] && continue + _key_in_list_test "${vkey}" "${VERIFY_PATH_KEYS}" || continue + _check="${vval#!}" + _check="${_check%% *}" + case "${_check}" in + /*) + [ -e "${_check}" ] || echo "WARN: ${vkey}=${vval} does not exist in container (host-specific path?)" + ;; + esac + done < <(git config --file "${TMP_GIT}" --list 2>/dev/null) +) + +if echo "${VERIFY_OUTPUT}" | grep -q "WARN: user.signingkey"; then + echo "PASS: verification warns for a missing signingkey path" +else + echo "FAIL: verification did not warn for a missing signingkey path" + rm -f "${TMP_GIT}" "${EXISTING_FILE}" + exit 1 +fi + +if echo "${VERIFY_OUTPUT}" | grep -q "WARN: gpg.program"; then + echo "FAIL: verification incorrectly warned for an existing gpg.program path with trailing flags" + rm -f "${TMP_GIT}" "${EXISTING_FILE}" + exit 1 +else + echo "PASS: verification stays silent for an existing gpg.program path despite trailing flags" +fi + +if echo "${VERIFY_OUTPUT}" | grep -q "WARN: core.editor"; then + echo "FAIL: verification incorrectly warned for a bare-command core.editor value" + rm -f "${TMP_GIT}" "${EXISTING_FILE}" + exit 1 +else + echo "PASS: verification stays silent for a non-absolute core.editor command" +fi + +if echo "${VERIFY_OUTPUT}" | grep -q "WARN: credential.helper"; then + echo "PASS: verification warns for a missing '!'-prefixed credential.helper path" +else + echo "FAIL: verification did not warn for a missing '!'-prefixed credential.helper path" + rm -f "${TMP_GIT}" "${EXISTING_FILE}" + exit 1 +fi +rm -f "${TMP_GIT}" "${EXISTING_FILE}" + # Test 6: SSH agent runtime detection script exists PROFILE_SSH="/etc/profile.d/dotfiles-sync-ssh.sh" if [ -f "$PROFILE_SSH" ]; then