From d341698468ab3e4e2d8bb915647060da1ffe6b6e Mon Sep 17 00:00:00 2001 From: baxyz Date: Tue, 7 Jul 2026 23:24:45 +0000 Subject: [PATCH 1/6] =?UTF-8?q?fix(dotfiles-sync):=20=F0=9F=90=9B=20genera?= =?UTF-8?q?lize=20gitconfig=20host-path=20rewrite=20and=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user.signingkey was rewritten from a host-absolute .ssh path to the container's TARGET_HOME/.ssh, but other bare-path keys (http.sslCert, http.sslKey, http.sslCAInfo) had the same class of bug and were copied verbatim. Generalize the rewrite to a small key allowlist, covering both .ssh/ and .gnupg/ targets. Also add a best-effort post-merge verification pass: for path-like keys that can't be deterministically rewritten (gpg.program, core.editor — host binary paths with no container equivalent), warn if the value doesn't resolve to an existing file instead of failing silently. Co-Authored-By: Claude Sonnet 5 --- src/dotfiles-sync/sync-files.sh | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/dotfiles-sync/sync-files.sh b/src/dotfiles-sync/sync-files.sh index b059dab..7f373b4 100755 --- a/src/dotfiles-sync/sync-files.sh +++ b/src/dotfiles-sync/sync-files.sh @@ -12,6 +12,12 @@ # .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 merging, path-like keys +# (signingkey, gpg.program, core.editor, 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. @@ -116,6 +122,15 @@ 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. + REHOMEABLE_PATH_KEYS="user.signingkey http.sslCert http.sslKey http.sslCAInfo" + MERGED=0 SKIPPED=0 while IFS= read -r line; do @@ -123,6 +138,19 @@ elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; t VAL="${line#*=}" [ -z "${KEY}" ] && continue + case " ${REHOMEABLE_PATH_KEYS} " in + *" ${KEY} "*) + case "${VAL}" in + */.ssh/*) + VAL="${TARGET_HOME}/.ssh/$(basename "${VAL}")" + ;; + */.gnupg/*) + VAL="${TARGET_HOME}/.gnupg/$(basename "${VAL}")" + ;; + esac + ;; + esac + # On cloud envs: skip protected keys if already present if [ "${IS_CLOUD_ENV}" = "true" ]; then _protected=false @@ -148,6 +176,21 @@ elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; t done < <(git config --file "${SOURCE_HOME}/.gitconfig" --list 2>/dev/null) echo " .gitconfig: merged (${MERGED} added, ${SKIPPED} protected)" + + # Verify path-like values resolve inside the container. Best-effort: + # warn, never fail — catches host/container mismatches the rewrite + # above doesn't cover (e.g. gpg.program or core.editor pointing at a + # host-only binary path, like a macOS Homebrew prefix that doesn't + # exist in a Linux container). + VERIFY_PATH_KEYS="user.signingkey gpg.program core.editor http.sslCert http.sslKey http.sslCAInfo" + for vkey in ${VERIFY_PATH_KEYS}; do + vval="$(_gitconfig_get "${TARGET_GIT}" "${vkey}")" + case "${vval}" in + /*) + [ -e "${vval}" ] || echo " WARN: ${vkey}=${vval} does not exist in container (host-specific path?)" + ;; + esac + done else # Fallback without git: copy source if target is empty if [ ! -s "${TARGET_GIT}" ]; then From ae9b4341194a0e14ca2461d02195a6d30c9a690f Mon Sep 17 00:00:00 2001 From: baxyz Date: Tue, 7 Jul 2026 23:24:50 +0000 Subject: [PATCH 2/6] =?UTF-8?q?test(dotfiles-sync):=20=E2=9C=85=20cover=20?= =?UTF-8?q?generalized=20rewrite=20and=20verify=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the REHOMEABLE_PATH_KEYS rewrite (including the .gnupg target and the non-allowlisted-key negative case) and the VERIFY_PATH_KEYS warning pass (missing vs. existing path) added in sync-files.sh. Co-Authored-By: Claude Sonnet 5 --- test/dotfiles-sync/test.sh | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/test/dotfiles-sync/test.sh b/test/dotfiles-sync/test.sh index 2f03ace..7d2234b 100755 --- a/test/dotfiles-sync/test.sh +++ b/test/dotfiles-sync/test.sh @@ -165,6 +165,92 @@ 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 in sync-files.sh — the host's home directory doesn't +# exist in the container, but the referenced file itself is re-homed under +# TARGET_HOME by the SSH/GPG sync steps). +REHOMEABLE_PATH_KEYS="user.signingkey http.sslCert http.sslKey http.sslCAInfo" +TEST_TARGET_HOME="/home/other-user" + +_rehome_test() { + local _key="$1" _val="$2" + case " ${REHOMEABLE_PATH_KEYS} " in + *" ${_key} "*) + case "${_val}" in + */.ssh/*) + _val="${TEST_TARGET_HOME}/.ssh/$(basename "${_val}")" + ;; + */.gnupg/*) + _val="${TEST_TARGET_HOME}/.gnupg/$(basename "${_val}")" + ;; + esac + ;; + esac + printf '%s' "${_val}" +} + +RESULT_SSH="$(_rehome_test "user.signingkey" "/home/some-host-user/.ssh/id_test_ed25519.pub")" +if [ "${RESULT_SSH}" = "/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 (got: ${RESULT_SSH})" + exit 1 +fi + +RESULT_GNUPG="$(_rehome_test "http.sslKey" "/home/some-host-user/.gnupg/client.key")" +if [ "${RESULT_GNUPG}" = "/home/other-user/.gnupg/client.key" ]; then + echo "PASS: http.sslKey .gnupg path rewritten to TARGET_HOME/.gnupg" +else + echo "FAIL: http.sslKey path not rewritten correctly (got: ${RESULT_GNUPG})" + exit 1 +fi + +RESULT_UNLISTED="$(_rehome_test "core.editor" "/home/some-host-user/.ssh/some-editor")" +if [ "${RESULT_UNLISTED}" = "/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 (got: ${RESULT_UNLISTED})" + exit 1 +fi + +# Test 5i: post-merge verification warns for path-like values that don't +# exist in the container and stays silent for ones that do (mirrors the +# VERIFY_PATH_KEYS loop in sync-files.sh). +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}" + +VERIFY_OUTPUT=$( + VERIFY_PATH_KEYS="user.signingkey gpg.program core.editor http.sslCert http.sslKey http.sslCAInfo" + for vkey in ${VERIFY_PATH_KEYS}; do + vval="$(git config --file "${TMP_GIT}" --get "${vkey}" 2>/dev/null)" + case "${vval}" in + /*) + [ -e "${vval}" ] || echo "WARN: ${vkey}=${vval} does not exist in container (host-specific path?)" + ;; + esac + done +) + +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" + rm -f "${TMP_GIT}" "${EXISTING_FILE}" + exit 1 +else + echo "PASS: verification stays silent for an existing gpg.program path" +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 From a1ceba0f2587da633360a1c23cdede0e2ca7ff11 Mon Sep 17 00:00:00 2001 From: baxyz Date: Tue, 7 Jul 2026 23:24:54 +0000 Subject: [PATCH 3/6] =?UTF-8?q?docs(dotfiles-sync):=20=F0=9F=93=9D=20docum?= =?UTF-8?q?ent=20path=20rewrite=20and=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dotfiles-sync/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/dotfiles-sync/README.md b/src/dotfiles-sync/README.md index 0f7d617..9c525a7 100644 --- a/src/dotfiles-sync/README.md +++ b/src/dotfiles-sync/README.md @@ -102,6 +102,13 @@ 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. +- **Verify**: after merging, the same keys plus `gpg.program` and `core.editor` (paths the rewrite can't fix, since they point at host binaries with no container equivalent — e.g. a macOS Homebrew prefix) are checked for existence in the container. 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. + ### 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: From 6520a10dd6ce3affa4204e76a8a2f19c8ce7743e Mon Sep 17 00:00:00 2001 From: baxyz Date: Wed, 8 Jul 2026 19:51:53 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(dotfiles-sync):=20=F0=9F=90=9B=20fix=20?= =?UTF-8?q?casing,=20ordering=20and=20scope=20of=20path=20rewrite/verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of the previous commit found the generalized rewrite/verify mechanism was broken in several ways: - REHOMEABLE_PATH_KEYS used git's documented mixed-case spelling (http.sslCert/sslKey/sslCAInfo), but `git config --list` always lowercases key names — so 3 of the 4 allowlisted keys never matched and were silently never rewritten. Only user.signingkey worked, incidentally, because it's already all-lowercase. - The verify pass ran inside the .gitconfig merge block, before the .ssh/.gnupg syncs that actually copy the rehomed files into place — so a freshly-rewritten user.signingkey was flagged as "missing" on every first sync, training users to ignore the warning. - gpg.program/core.editor can hold "command --flags" strings, not bare paths; checking the full value for existence false-positived on any such value that happened to start with "/". - The .gnupg rewrite collapsed to basename only, losing subdirectory structure that the actual (recursive) .gnupg sync preserves. - credential.helper is flagged as host-specific in PROTECTED_KEYS but was missing from both new lists, despite commonly holding an absolute path to a custom helper script. Fixes: lowercase the allowlist to match git's normalization; move the verify pass to run after the .ssh/.gnupg syncs; strip a leading "!" and trailing arguments before checking path-like values; preserve the relative path (not just basename) when rewriting; add credential.helper as verify-only (not rewritten, since a blind rewrite would corrupt its "!cmd args" shell-invocation syntax); derive VERIFY_PATH_KEYS from REHOMEABLE_PATH_KEYS instead of hand-duplicating it, so the two can't drift apart again; and introduce a shared _key_in_list() helper used by PROTECTED_KEYS, REHOMEABLE_PATH_KEYS and VERIFY_PATH_KEYS instead of two different allowlist-membership idioms. Verified end-to-end against a synthetic host .gitconfig/.ssh/.gnupg fixture: mixed-case keys rewrite correctly, no false-positive warnings on a fresh sync, nested .gnupg paths preserve their subdirectory, and a missing credential.helper path is now caught. Co-Authored-By: Claude Sonnet 5 --- src/dotfiles-sync/sync-files.sh | 115 ++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 43 deletions(-) diff --git a/src/dotfiles-sync/sync-files.sh b/src/dotfiles-sync/sync-files.sh index 7f373b4..8bad35b 100755 --- a/src/dotfiles-sync/sync-files.sh +++ b/src/dotfiles-sync/sync-files.sh @@ -14,10 +14,11 @@ # 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 merging, path-like keys -# (signingkey, gpg.program, core.editor, http.ssl*) are -# checked for existence in the container and a WARN is -# printed (not fatal) if a host-specific path didn't survive. +# 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. @@ -109,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 @@ -129,7 +140,20 @@ elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; t # 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. - REHOMEABLE_PATH_KEYS="user.signingkey http.sslCert http.sslKey http.sslCAInfo" + # + # 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 @@ -138,33 +162,26 @@ elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; t VAL="${line#*=}" [ -z "${KEY}" ] && continue - case " ${REHOMEABLE_PATH_KEYS} " in - *" ${KEY} "*) - case "${VAL}" in - */.ssh/*) - VAL="${TARGET_HOME}/.ssh/$(basename "${VAL}")" - ;; - */.gnupg/*) - VAL="${TARGET_HOME}/.gnupg/$(basename "${VAL}")" - ;; - esac - ;; - esac + 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 @@ -176,21 +193,6 @@ elif [ -f "${SOURCE_HOME}/.gitconfig" ] && [ -s "${SOURCE_HOME}/.gitconfig" ]; t done < <(git config --file "${SOURCE_HOME}/.gitconfig" --list 2>/dev/null) echo " .gitconfig: merged (${MERGED} added, ${SKIPPED} protected)" - - # Verify path-like values resolve inside the container. Best-effort: - # warn, never fail — catches host/container mismatches the rewrite - # above doesn't cover (e.g. gpg.program or core.editor pointing at a - # host-only binary path, like a macOS Homebrew prefix that doesn't - # exist in a Linux container). - VERIFY_PATH_KEYS="user.signingkey gpg.program core.editor http.sslCert http.sslKey http.sslCAInfo" - for vkey in ${VERIFY_PATH_KEYS}; do - vval="$(_gitconfig_get "${TARGET_GIT}" "${vkey}")" - case "${vval}" in - /*) - [ -e "${vval}" ] || echo " WARN: ${vkey}=${vval} does not exist in container (host-specific path?)" - ;; - esac - done else # Fallback without git: copy source if target is empty if [ ! -s "${TARGET_GIT}" ]; then @@ -335,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. From ad12e60e4d8c7a947aa6674afc6d164e6c6372a1 Mon Sep 17 00:00:00 2001 From: baxyz Date: Wed, 8 Jul 2026 19:52:02 +0000 Subject: [PATCH 5/6] =?UTF-8?q?test(dotfiles-sync):=20=E2=9C=85=20route=20?= =?UTF-8?q?rewrite=20test=20through=20real=20key=20normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Test 5h called the rewrite helper directly with the mixed-case key spelling ("http.sslKey"), so it never exercised the `git config --list` normalization step that broke production (keys are always lowercased) — it reported a false PASS while the equivalent real flow silently never rewrote the value. Rewritten to build a real git-config file and route it through `git config --file ... --list`, same as sync-files.sh does, plus a nested .gnupg subdirectory case. Test 5i now mirrors the corrected single-pass, post-sync verify loop: covers the "!"-prefix and trailing-argument stripping (credential.helper, gpg.program, core.editor) and confirms a bare-command core.editor value doesn't false-positive. Sanity-checked: reverting REHOMEABLE_PATH_KEYS to the old mixed-case spelling makes the new Test 5h fail, confirming it's a real regression guard for the bug it replaces coverage for. Co-Authored-By: Claude Sonnet 5 --- test/dotfiles-sync/test.sh | 132 +++++++++++++++++++++++++------------ 1 file changed, 91 insertions(+), 41 deletions(-) diff --git a/test/dotfiles-sync/test.sh b/test/dotfiles-sync/test.sh index 7d2234b..09aecaf 100755 --- a/test/dotfiles-sync/test.sh +++ b/test/dotfiles-sync/test.sh @@ -167,71 +167,105 @@ 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 in sync-files.sh — the host's home directory doesn't -# exist in the container, but the referenced file itself is re-homed under -# TARGET_HOME by the SSH/GPG sync steps). -REHOMEABLE_PATH_KEYS="user.signingkey http.sslCert http.sslKey http.sslCAInfo" +# 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" -_rehome_test() { - local _key="$1" _val="$2" - case " ${REHOMEABLE_PATH_KEYS} " in - *" ${_key} "*) - case "${_val}" in - */.ssh/*) - _val="${TEST_TARGET_HOME}/.ssh/$(basename "${_val}")" - ;; - */.gnupg/*) - _val="${TEST_TARGET_HOME}/.gnupg/$(basename "${_val}")" - ;; - esac - ;; - esac - printf '%s' "${_val}" +_key_in_list_test() { + local _needle="$1" _item + for _item in ${2}; do + [ "${_needle}" = "${_item}" ] && return 0 + done + return 1 } -RESULT_SSH="$(_rehome_test "user.signingkey" "/home/some-host-user/.ssh/id_test_ed25519.pub")" -if [ "${RESULT_SSH}" = "/home/other-user/.ssh/id_test_ed25519.pub" ]; then +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 (got: ${RESULT_SSH})" + echo "FAIL: user.signingkey path not rewritten correctly" + echo "${REWRITTEN}" exit 1 fi -RESULT_GNUPG="$(_rehome_test "http.sslKey" "/home/some-host-user/.gnupg/client.key")" -if [ "${RESULT_GNUPG}" = "/home/other-user/.gnupg/client.key" ]; then - echo "PASS: http.sslKey .gnupg path rewritten to TARGET_HOME/.gnupg" +# 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.sslKey path not rewritten correctly (got: ${RESULT_GNUPG})" + echo "FAIL: http.sslCert path not rewritten correctly (mixed-case key or nested-path regression)" + echo "${REWRITTEN}" exit 1 fi -RESULT_UNLISTED="$(_rehome_test "core.editor" "/home/some-host-user/.ssh/some-editor")" -if [ "${RESULT_UNLISTED}" = "/home/some-host-user/.ssh/some-editor" ]; then +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 (got: ${RESULT_UNLISTED})" + echo "FAIL: rehome rewrite touched a key outside REHOMEABLE_PATH_KEYS" + echo "${REWRITTEN}" exit 1 fi -# Test 5i: post-merge verification warns for path-like values that don't -# exist in the container and stays silent for ones that do (mirrors the -# VERIFY_PATH_KEYS loop in sync-files.sh). +# 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}" +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=$( - VERIFY_PATH_KEYS="user.signingkey gpg.program core.editor http.sslCert http.sslKey http.sslCAInfo" - for vkey in ${VERIFY_PATH_KEYS}; do - vval="$(git config --file "${TMP_GIT}" --get "${vkey}" 2>/dev/null)" - case "${vval}" in + 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 "${vval}" ] || echo "WARN: ${vkey}=${vval} does not exist in container (host-specific path?)" + [ -e "${_check}" ] || echo "WARN: ${vkey}=${vval} does not exist in container (host-specific path?)" ;; esac - done + done < <(git config --file "${TMP_GIT}" --list 2>/dev/null) ) if echo "${VERIFY_OUTPUT}" | grep -q "WARN: user.signingkey"; then @@ -243,11 +277,27 @@ else fi if echo "${VERIFY_OUTPUT}" | grep -q "WARN: gpg.program"; then - echo "FAIL: verification incorrectly warned for an existing gpg.program path" + 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" + 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}" From 2bc644a268bd7d0197173386226bb7e1415a82e3 Mon Sep 17 00:00:00 2001 From: baxyz Date: Wed, 8 Jul 2026 19:52:08 +0000 Subject: [PATCH 6/6] =?UTF-8?q?docs(dotfiles-sync):=20=F0=9F=93=9D=20corre?= =?UTF-8?q?ct=20rewrite/verify=20docs=20after=20bugfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the merge-strategy section to reflect the fixes: relative-path preservation for nested .gnupg values, verify running after the .ssh/.gnupg syncs, and credential.helper covered as verify-only. Note the git key-lowercasing behavior the allowlists depend on. Co-Authored-By: Claude Sonnet 5 --- src/dotfiles-sync/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/dotfiles-sync/README.md b/src/dotfiles-sync/README.md index 9c525a7..4836daf 100644 --- a/src/dotfiles-sync/README.md +++ b/src/dotfiles-sync/README.md @@ -106,8 +106,10 @@ That's it. The feature auto-detects the environment and adapts its behavior. 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. -- **Verify**: after merging, the same keys plus `gpg.program` and `core.editor` (paths the rewrite can't fix, since they point at host binaries with no container equivalent — e.g. a macOS Homebrew prefix) are checked for existence in the container. 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. +- **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