patchette

fix: STACK base and tip must be full commit IDs

commit 9cd6edf32ec8c7150335c2019a246c1982313237 · parent 46343bf21d · browse this tree
Ximianth, 2026-10-21 09:12:03 UTC

parse_stack took base and tip verbatim.  Anything git could resolve got
through: `base main` reached replay and died inside hash-object, with
the error pinned on the first patch file rather than the STACK; an
abbreviated tip was reported as a MISMATCH against a replay that was
in fact correct; and `tip ` with no value parsed as an absent tip, so
verify passed without cross-checking anything.

FORMAT.md now states the rule: full lowercase hex, 40 characters for
SHA-1 or 64 for SHA-256.  Abbreviations and ref names are refused
because they can resolve differently from repo to repo, or over time.
parse_stack checks each value as it reads it, so the error names the
STACK and the offending value.

The three new fail-closed cases each failed before this change.  The
64-character form isn't covered by the suite (there's no SHA-256
corpus); a SHA-256 export and verify was checked by hand.
diff --git a/bin/patchette b/bin/patchette
index 5355755..cd4762e 100755
--- a/bin/patchette
+++ b/bin/patchette
@@ -91,6 +91,7 @@ absdir() {
 ## - @state STACK_REF, STACK_BASE, STACK_TIP, STACK_SERIES[] -- the parsed fields
 parse_stack() {
 	local f=$1 line mode=head
+	local oid='^[0-9a-f]{40}([0-9a-f]{24})?$' # SHA-1 or SHA-256, full and lowercase
 	declare -A seen=()
 	STACK_REF='' STACK_BASE='' STACK_TIP='' STACK_SERIES=()
 	while IFS= read -r line; do
@@ -103,8 +104,14 @@ parse_stack() {
 		fi
 		case $line in
 		"ref "*) STACK_REF=${line#ref } ;;
-		"base "*) STACK_BASE=${line#base } ;;
-		"tip "*) STACK_TIP=${line#tip } ;;
+		"base "*)
+			STACK_BASE=${line#base }
+			[[ $STACK_BASE =~ $oid ]] || die "$f: base is not a full commit ID: $STACK_BASE"
+			;;
+		"tip "*)
+			STACK_TIP=${line#tip }
+			[[ $STACK_TIP =~ $oid ]] || die "$f: tip is not a full commit ID: $STACK_TIP"
+			;;
 		series) mode=series ;;
 		*) die "$f: unrecognized line: $line" ;;
 		esac
diff --git a/docs/hacking/FORMAT.md b/docs/hacking/FORMAT.md
index 96950bd..99d14dc 100644
--- a/docs/hacking/FORMAT.md
+++ b/docs/hacking/FORMAT.md
@@ -110,6 +110,10 @@ Of the key value entries:
   It may also be disregarded in some workflows: e.g., when hand-editing patch files,
   there's no expectable value for tip until the branch has been built.
 
+The `base` and `tip` values are full commit IDs in lowercase hex:
+40 characters in a SHA-1 repo, 64 in a SHA-256 one.
+Abbreviations and ref names are errors (they can resolve differently from repo to repo, or over time).
+
 In the `series` section:
 
 - each line is a patch name.  These match `^[a-z0-9-]+$`,
diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh
index 6c77dd5..92754d6 100755
--- a/tests/test-failclosed.sh
+++ b/tests/test-failclosed.sh
@@ -71,6 +71,21 @@ sed -i '1s/.*/parents no-such-slug/' "$TAMPER/$fancy/add-sprocket-docs.patch"
 EXPECT refusal "an unknown parent reference is an error" "not BASE or an earlier slug" -- \
 	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
 
+# base and tip must be full commit IDs: a ref name or an abbreviation could
+# resolve differently in another repo, or later, so neither is guessed at.
+clone_layout refbase
+sed -i 's/^base .*/base main/' "$TAMPER/$fancy/STACK"
+EXPECT refusal "a ref name as base is an error" "base is not a full commit ID" -- \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+clone_layout shorttip
+sed -i 's/^tip \(.\{12\}\).*/tip \1/' "$TAMPER/$fancy/STACK"
+EXPECT refusal "an abbreviated tip is an error" "tip is not a full commit ID" -- \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+clone_layout emptytip
+sed -i 's/^tip .*/tip /' "$TAMPER/$fancy/STACK"
+EXPECT refusal "a tip line with no value is an error, not an absent tip" "tip is not a full commit ID" -- \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+
 # A duplicate series entry is an error.
 clone_layout dupseries
 echo "note-to-self" >>"$TAMPER/$fancy/STACK"