Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/dotfiles-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<relative-path>` or `TARGET_HOME/.gnupg/<relative-path>` — 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:
Expand Down
94 changes: 83 additions & 11 deletions src/dotfiles-sync/sync-files.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: <needle> <space-separated list>
_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
Expand All @@ -116,27 +133,55 @@ 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
KEY="${line%%=*}"
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
Expand Down Expand Up @@ -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.
Expand Down
136 changes: 136 additions & 0 deletions test/dotfiles-sync/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down