patchette

fix: git env inherited from a caller can no longer redirect or reshape patchette

commit 040980822a9116b01b4232ead6500495ecc746c7 · parent 46343bf21d · browse this tree
Ximianth, 2026-10-21 09:10:54 UTC

The hermetic setup nulled global and system config, but left the
environment a parent git hands down -- inside hooks, aliases, or under
`git -c` -- free to reach every git call.  Nine hostile env cases in
tests/test-hermetic.sh, all failing before this change:

- wrong repo: GIT_DIR made import create its refs in the decoy repo
  rather than the one named on the command line; GIT_COMMON_DIR and
  GIT_OBJECT_DIRECTORY broke export and verify outright.
- wrong commits: GIT_WORK_TREE made import replay different SHAs,
  exiting 0.
- churn: GIT_CONFIG_COUNT/KEY/VALUE and GIT_CONFIG_PARAMETERS (how
  `git -c` travels) injected config that changed export bytes;
  GIT_ATTR_SOURCE turned every diff binary; GIT_DIFF_OPTS changed the
  context; GIT_GRAFT_FILE rewrote parents, so export refused the range.

The fix unsets git's own list of repo-local variables
(`git rev-parse --local-env-vars`: "GIT_DIR or GIT_WORK_TREE, but not
GIT_EDITOR"), so vars git adds to that list later are covered too.
GIT_ATTR_SOURCE and GIT_DIFF_OPTS aren't on it and are named
explicitly; with either one removed, its own case fails again.
GIT_NAMESPACE, GIT_EXTERNAL_DIFF, and GIT_DEFAULT_HASH were probed too
and had no effect, so they're left alone.
diff --git a/bin/patchette b/bin/patchette
index 5355755..d17e45b 100755
--- a/bin/patchette
+++ b/bin/patchette
@@ -22,6 +22,11 @@ set -euo pipefail
 # Hermetic git: no user or system config, no system attributes, fixed locale.
 # Deliberately NO author/committer identities or dates: patchette never authors
 # commits; replayed commits carry their own bytes.
+# Nor any git env inherited from a caller (hooks, aliases, `git -c`):
+# git's own list of repo-local vars covers redirecting which repo we read and
+# write, injected config, and grafts; the other two would perturb export bytes.
+git_local_env=$(git rev-parse --local-env-vars)
+unset $git_local_env GIT_ATTR_SOURCE GIT_DIFF_OPTS
 export GIT_CONFIG_GLOBAL=/dev/null
 export GIT_CONFIG_SYSTEM=/dev/null
 export GIT_CONFIG_NOSYSTEM=1
diff --git a/tests/test-hermetic.sh b/tests/test-hermetic.sh
index f902f07..51835b5 100755
--- a/tests/test-hermetic.sh
+++ b/tests/test-hermetic.sh
@@ -1,9 +1,12 @@
 #!/bin/bash
 ## Hermeticity: hostile repo-local git config and attributes, in the subject
-## or the target repo, must not change what patchette writes or replays.
+## or the target repo, or hostile git env inherited from a caller,
+## must not change what patchette writes or replays.
 ##
 ## Nulling global and system config doesn't reach a repo's own .git/config,
 ## its info/attributes, or a worktree .gitattributes; git reads all of those.
+## Nor does it reach env a parent git hands down (hooks, aliases, `git -c`),
+## which can inject config or redirect which repo is read and written.
 ## Each case plants one hostile setup into a copy of a repo, then checks:
 ## export bytes match a clean export, verify passes, and import reproduces exact tips.
 set -euo pipefail
@@ -19,12 +22,20 @@ branches=(topic/fancy docs-polish odd-bytes)
 want_tips=$(printf '%s\n' "$tip_topic_fancy" "$tip_docs_polish" "$tip_odd_bytes")
 "$PATCHETTE" export "$work/corpus/dev" "$work/clean" "$base" "${branches[@]}" >/dev/null
 printf 'odd/trailing.txt\n' >"$work/orderfile"
+# Env-case fixtures: a tree holding a hostile .gitattributes (in the subject's
+# object store), a graft that turns docs-polish's tip into a root,
+# and a decoy repo that holds the base, so a redirected import could succeed there.
+attrs_tree=$(printf '100644 blob %s\t.gitattributes\n' "$(printf '* -diff\n' | git -C "$work/corpus/dev" hash-object -w --stdin)" |
+	git -C "$work/corpus/dev" mktree)
+printf '%s\n' "$tip_docs_polish" >"$work/grafts"
+git clone -q --bare "$work/corpus/upstream" "$work/decoy.git"
 
 # Each case is `|`-separated items:
 # `key=value` sets repo-local config; `@line` appends to the repo's info/attributes;
 # `%line` appends to an uncommitted worktree .gitattributes;
 # `!line` commits a .gitattributes on a new branch and checks it out
-# (export bytes must not vary with which branch happens to be checked out).
+# (export bytes must not vary with which branch happens to be checked out);
+# `$VAR=value` sets env for the patchette invocations only.
 cases=(
 	diff.context=1
 	diff.context=8
@@ -76,18 +87,30 @@ cases=(
 	'%* -diff'
 	'!* -diff'
 	'!*.c diff=cpp'
+	'$GIT_CONFIG_COUNT=1|$GIT_CONFIG_KEY_0=diff.suppressBlankEmpty|$GIT_CONFIG_VALUE_0=true'
+	"\$GIT_CONFIG_PARAMETERS='core.quotepath'='false'"
+	"\$GIT_ATTR_SOURCE=$attrs_tree"
+	'$GIT_DIFF_OPTS=--unified=1'
+	"\$GIT_GRAFT_FILE=$work/grafts"
+	"\$GIT_DIR=$work/decoy.git"
+	"\$GIT_COMMON_DIR=$work/decoy.git"
+	"\$GIT_OBJECT_DIRECTORY=$work/decoy.git/objects"
+	"\$GIT_WORK_TREE=$work"
 )
 
 ## Plant one hostile case into a repo.
 ##
 ## - @param $1 repo path (non-bare)
 ## - @param $2 case string, as described above the table
+## - @state HOSTILE_ENV[] -- `VAR=value` words, for `env` to prefix patchette with
 hostilize() {
 	local repo=$1 item
 	local -a items
+	HOSTILE_ENV=()
 	IFS='|' read -ra items <<<"$2"
 	for item in "${items[@]}"; do
 		case $item in
+		'$'*) HOSTILE_ENV+=("${item#\$}") ;;
 		@*) printf '%s\n' "${item#@}" >>"$repo/.git/info/attributes" ;;
 		%*) printf '%s\n' "${item#%}" >>"$repo/.gitattributes" ;;
 		'!'*)
@@ -110,15 +133,15 @@ for c in "${cases[@]}"; do
 	# Command failures here surface on stderr just above the FAIL they cause.
 	cp -a "$work/corpus/dev" "$d/subject"
 	hostilize "$d/subject" "$c"
-	"$PATCHETTE" export "$d/subject" "$d/out" "$base" "${branches[@]}" >/dev/null || true
+	env "${HOSTILE_ENV[@]}" "$PATCHETTE" export "$d/subject" "$d/out" "$base" "${branches[@]}" >/dev/null || true
 	EXPECT success "subject has $c: export bytes match a clean export" -- \
 		diff -r "$work/clean" "$d/out"
 	EXPECT success "subject has $c: verify passes" -- \
-		"$PATCHETTE" verify "$d/subject" "$work/clean"
+		env "${HOSTILE_ENV[@]}" "$PATCHETTE" verify "$d/subject" "$work/clean"
 
 	git clone -q "$work/corpus/upstream" "$d/target"
 	hostilize "$d/target" "$c"
-	"$PATCHETTE" import "$d/target" "$work/clean" >/dev/null || true
+	env "${HOSTILE_ENV[@]}" "$PATCHETTE" import "$d/target" "$work/clean" >/dev/null || true
 	EXPECT equal "target has $c: import reproduces exact tips" -- \
 		"$(git -C "$d/target" rev-parse "${branches[@]/#/refs/heads/}" 2>/dev/null || true)" "$want_tips"
 done