patchette

Merge branch 'stack-oid-validation'

commit 2df663fec9d0fba880b97198c4a163620efe9f0b · parent 00e17472b7 · parent 14dda3a13d · browse this tree
warpfork, 2026-10-21 16:13:03 UTC

diff --git a/bin/patchette b/bin/patchette
index d17e45b..46e9d49 100755
--- a/bin/patchette
+++ b/bin/patchette
@@ -95,8 +95,9 @@ absdir() {
 ## - @param $1 path to the STACK file
 ## - @state STACK_REF, STACK_BASE, STACK_TIP, STACK_SERIES[] -- the parsed fields
 parse_stack() {
-	local f=$1 line mode=head
-	declare -A seen=()
+	local f=$1 line mode=head key
+	local oid='^[0-9a-f]{40}([0-9a-f]{24})?$' # SHA-1 or SHA-256, full and lowercase
+	declare -A seen=() keys_seen=()
 	STACK_REF='' STACK_BASE='' STACK_TIP='' STACK_SERIES=()
 	while IFS= read -r line; do
 		if [[ $mode == series ]]; then
@@ -106,10 +107,19 @@ parse_stack() {
 			STACK_SERIES+=("$line")
 			continue
 		fi
+		key=${line%% *}
+		[[ -z ${keys_seen[$key]:-} ]] || die "$f: duplicate $key line"
+		keys_seen[$key]=1
 		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..07f9df8 100644
--- a/docs/hacking/FORMAT.md
+++ b/docs/hacking/FORMAT.md
@@ -97,6 +97,7 @@ fix-the-frobnicator-2
 Several pieces of `key value` format data start the file,
 then a literal `series` line denotes the end of that section,
 and then one patch file name is per line, until EOF.
+Each key appears at most once.
 
 Of the key value entries:
 
@@ -110,6 +111,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..31a0164 100755
--- a/tests/test-failclosed.sh
+++ b/tests/test-failclosed.sh
@@ -71,6 +71,27 @@ 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 repeated STACK key is an error: which value is meant can't be known.
+clone_layout dupkey
+sed -i '1a ref refs/heads/other' "$TAMPER/$fancy/STACK"
+EXPECT refusal "a repeated STACK key is an error" "duplicate ref line" -- \
+	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
+
 # A duplicate series entry is an error.
 clone_layout dupseries
 echo "note-to-self" >>"$TAMPER/$fancy/STACK"