patchette

test-failclosed.sh

#!/bin/bash
## Fail-closed behavior: every tampered or conflicting input is refused,
## loudly, and for its own specific reason (we check the message, not just
## the exit code, so each case stays tied to the guard it exercises).
set -euo pipefail
cd "$(dirname "$0")"
source ./lib.sh

work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT

make_corpus "$work/corpus"
source "$work/corpus/FACTS"

"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/pristine" "$base" topic/fancy docs-polish >/dev/null
fancy=$'topic\u00a6fancy'

## Set up a fresh tampering workspace from the pristine export.
##
## - @param $1 case name; becomes the folder name under $work
## - @state TAMPER -- path to the fresh copy
clone_layout() {
	TAMPER="$work/$1"
	cp -r "$work/pristine" "$TAMPER"
}

# A tampered patch must make verify fail.  Editing message content keeps the
# diff applying cleanly, so the only witness left is the tip comparison --
# exactly the guard this case is about.
clone_layout tampered
sed -i 's/> The hum is load-bearing./> The hum is decorative./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch"
EXPECT refusal "verify fails on a tampered patch" "MISMATCH" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"

# Import onto an existing branch is refused (before any replay work).
git clone -q "$work/corpus/upstream" "$work/occupied"
git -C "$work/occupied" branch docs-polish main
EXPECT refusal "import onto an existing branch is refused" "already exists" -- \
	"$PATCHETTE" import --subject="$work/occupied" --patches="$work/pristine"

# A malformed message line is a parse error, never a guess.
clone_layout badmsg
sed -i 's/> Do not ask why./| Do not ask why./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch"
EXPECT refusal "a malformed message line is a parse error" "malformed message line" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"

# An unrecognized bare line in directive position (directly after the
# opening flank) is an error.
clone_layout baddirective
sed -i '0,/^----$/s//----\nmystery-directive/' "$TAMPER/$fancy/note-to-self.patch"
EXPECT refusal "an unknown directive line is an error" "unrecognized directive" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"

# A directive is only legal before message content; after content has begun,
# the same bare line is just a malformed message line.
clone_layout latedirective
sed -i 's/^> Note to self$/> Note to self\nmessage-no-final-newline/' "$TAMPER/$fancy/note-to-self.patch"
EXPECT refusal "a directive after message content is a parse error" "malformed message line" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"

# The directive's pre-spec position (bare line in the header area) must fail
# loudly, not be swallowed as a verbatim header into the replayed commit.
clone_layout olddirective
sed -i '1a message-no-final-newline' "$TAMPER/$fancy/note-to-self.patch"
EXPECT refusal "a bare line in the header area is a parse error" "malformed header line" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"

# An unknown parent reference is an error.
clone_layout badparent
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 --subject="$work/corpus/dev" --patches="$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 --subject="$work/corpus/dev" --patches="$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 --subject="$work/corpus/dev" --patches="$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 --subject="$work/corpus/dev" --patches="$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 --subject="$work/corpus/dev" --patches="$TAMPER"

# A duplicate series entry is an error.
clone_layout dupseries
echo "note-to-self" >>"$TAMPER/$fancy/STACK"
EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"

# Folder discordances are errors for every reader: an orphan patch file
# (nothing would ever read it) and a series entry naming no file must be
# refused by verify and import alike.
clone_layout orphan
cp "$TAMPER/$fancy/note-to-self.patch" "$TAMPER/$fancy/stray.patch"
EXPECT refusal "an orphan patch file is refused by verify" "orphan" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
git clone -q "$work/corpus/upstream" "$work/orphanclone"
EXPECT refusal "an orphan patch file is refused by import" "orphan" -- \
	"$PATCHETTE" import --subject="$work/orphanclone" --patches="$TAMPER"
EXPECT equal "the refused import created no refs" -- \
	"$(git -C "$work/orphanclone" for-each-ref --format='%(refname)' 'refs/heads/*' --exclude refs/heads/main)" ""

clone_layout missingfile
rm "$TAMPER/$fancy/note-to-self.patch"
EXPECT refusal "a series entry naming no file is refused by verify" "'note-to-self.patch' is missing" -- \
	"$PATCHETTE" verify --subject="$work/corpus/dev" --patches="$TAMPER"
EXPECT refusal "a series entry naming no file is refused by import" "'note-to-self.patch' is missing" -- \
	"$PATCHETTE" import --subject="$work/orphanclone" --patches="$TAMPER"

# Export never deletes a folder it didn't write.
mkdir -p "$work/notours/$fancy"
touch "$work/notours/$fancy/precious.txt"
EXPECT refusal "export into a non-patchette folder is refused" "no STACK file" -- \
	"$PATCHETTE" export --subject="$work/corpus/dev" --patches="$work/notours" "$base" topic/fancy
EXPECT success "export left the non-patchette folder untouched" -- \
	test -f "$work/notours/$fancy/precious.txt"

# A failed export must leave the patches dir as it found it: the previous
# snapshot intact, and no half-written folder that could pass for a real one.
# Trigger: rewrite topic/fancy onto a commit forked from BEFORE the base,
# so its parent is out of range and export dies mid-branch.
cp -a "$work/corpus/dev" "$work/poisoned"
git -C "$work/poisoned" switch -qc side "$base^"
git -C "$work/poisoned" commit -q --allow-empty -m "Out of range"
git -C "$work/poisoned" branch -f topic/fancy side
clone_layout atomic
EXPECT refusal "export dies on an out-of-range parent" "out-of-range" -- \
	"$PATCHETTE" export --subject="$work/poisoned" --patches="$TAMPER" "$base" topic/fancy docs-polish
EXPECT success "the failed re-export left the previous snapshot untouched" -- \
	diff -r "$work/pristine" "$TAMPER"
mkdir "$work/freshout"
EXPECT refusal "export into a fresh dir dies the same way" "out-of-range" -- \
	"$PATCHETTE" export --subject="$work/poisoned" --patches="$work/freshout" "$base" topic/fancy
EXPECT equal "the failed fresh export left no debris behind" -- \
	"$(ls -A "$work/freshout")" ""

# A commit walk that fails must fail the export, not read as an empty branch:
# an empty series swapped in would silently replace a good snapshot.
# Trigger: a subject missing one mid-branch commit object.
cp -a "$work/corpus/dev" "$work/holey-dev"
mid=$(git -C "$work/holey-dev" rev-parse topic/fancy~3)
rm "$work/holey-dev/.git/objects/${mid:0:2}/${mid:2}"
clone_layout holey
EXPECT refusal "export dies when the commit walk fails" "could not list the commits of 'topic/fancy'" -- \
	"$PATCHETTE" export --subject="$work/holey-dev" --patches="$TAMPER" "$base" topic/fancy
EXPECT success "the failed walk left the previous snapshot untouched" -- \
	diff -r "$work/pristine" "$TAMPER"

# Importing where the recorded base is absent tells the user what to do.
git init -q -b main "$work/empty"
EXPECT refusal "import without the recorded base points at fetching" "fetch from upstream first" -- \
	"$PATCHETTE" import --subject="$work/empty" --patches="$work/pristine"

exit "$RESULT"

tests/test-failclosed.sh · 8.3 KiB · 023d3c7674