Crash: wizard createObj accepts out-of-range Which, panicking on seven unguarded array dispatches #10
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
createObjreads a raw nibble from the keyboard and stores it asWhichwithout any bounds check (
game/wizard.go:18-23):So
Whichcan be 0–15 (and negative for input below'a'). Everydownstream dispatch is a fixed-size array index with no guard:
game/sticks.go:34zapHandlers[obj.WandKind()]NumWandTypes(14)game/potions.go:45quaffHandlers[obj.PotionKind()]NumPotionTypes(14)game/scrolls.go:32readHandlers[obj.ScrollKind()]NumScrollTypesgame/potions.go:51,game/scrolls.go:39,170,171,game/things.go:53Items.Potions[obj.Which],Items.Scrolls[obj.Which]game/wizard.go:74aClass[obj.Which]NumArmorTypes(8)game/weapons.go:162initWeaps[which](reached fromwizard.go:61)NumWeaponTypes(9)game/sticks.go:505Items.WandType[cur.Which]Reproduce: wizard mode →
C→/(wand) →f→ index 15 into a14-element array → panic.
Why it matters more in Go than in C
C indexed past the end of a static array: undefined behavior, but in practice
it read adjacent garbage and kept going, or fell into
otherwise: msg("what a bizarre schtick!"). Go panics — and since refactorstep 8 made one game run one process (
myExit→os.Exit), an unrecoveredpanic takes the whole game down and leaves the terminal in raw mode.
So a C quirk that was survivable has become a hard crash. This is a genuine
divergence from 5.4.4, not merely a hardening opportunity.
Definition of done
createObjvalidatesWhichagainst the valid range for the chosen itemkind and re-prompts or rejects out-of-range input, rather than storing it.
Match C's user-facing behavior where C had defined behavior.
can index out of bounds even if a malformed object arrives from elsewhere
(notably: a restored save file).
not panic — plus at least one test per guarded dispatch family
(wand / potion / scroll / armor / weapon).
Which(input below'a'and below'0') is covered too.make checkfully green.TODO.mdupdated in the same commit.(closes #N).Implementation requirements
wizard.c create_obj()and thesticks.cdispatch(
git show origin/c-master:wizard.c,…:sticks.c) before choosing therejection behavior. Do NOT check out or modify
origin/c-master.over emulating garbage reads, and note the deliberate divergence in a code
comment. Do not invent new user-facing message text where C had some.
"what a bizarre schtick!"message is tracked separately —do not fold it in here beyond what the bounds fix strictly needs.
identical for every in-range case.
t.Parallel()and the//nolint:testpackageheader.maketargets only. Do NOT modify.golangci.yml.Priority
Highest of the open code issues — it is the only known way to crash the game
from normal (wizard-mode) input.
Implementation plan
Read
wizard.c create_obj()andsticks.c do_zap()/fix_stick()onorigin/c-master(viagit show, no checkout). Findings that shape the fix:create_obj()stores the nibble unchecked(
obj->o_which = isdigit(ch) ? ch - '0' : ch - 'a' + 10) and nevervalidates. Every downstream use is either a
switch(defined behavior in C,even for a bogus value) or a static-array read past the end (undefined). So
C has no defined user-facing behavior to be faithful to here — this is
exactly the "prefer a clean rejection" case.
do_zap()'s switch has no array index at all in C; theotherwisearm isthe
MASTER-only"what a bizarre schtick!"message, and thenobj->o_charges--runs regardless. WithoutMASTER, C silently did nothingand still burned a charge. That gives the Go guard a zero-invention
semantic: an out-of-range wand yields a
nilhandler, no effect, andobj.Charges--still runs. Restoring the message stays with #13.1. One shared bounds predicate (
game/object.go)whichLimit(kind ObjectKind) int— how many entries the kind's per-kindtables have:
NumPotionTypes,NumScrollTypes,NumRingTypes,NumWandTypes,NumArmorTypes;NumWeaponTypes + 1for weapons (theItems.Weaponsarray is deliberately sized+1for theWeaponFlamedragon-breath entry that
fireBoltreally does set on a liveObject);0for food / amulet / gold /KindNone, whoseWhichis not a tableindex — those keep accepting anything, exactly as today.
(*Object).hasValidWhich() bool—limit == 0 || 0 <= Which < limit.2. Reject at the two boundaries where a bad
Whichcan entercreateObj): after reading the nibble, reject out-of-rangewith a message and return without adding to the pack. The message reuses
C's existing
type_name()vocabulary (ObjectKind.String()) rather thaninventing a new noun. Weapons validate against
NumWeaponTypes(not+1):WeaponFlamehas a name-table entry but noinit_dam[]row, so itis not creatable. A code comment records the deliberate divergence from
5.4.4. The check sits before any
rnd()call, so RNG order is untouchedfor every valid input and for the rejected path alike.
Restore): validate everyObjectin the snapshot(
st.Objects,st.Player.Body.Pack, eachst.Monsters[i].Pack) andreturn a new
ErrSaveCorrupt-style error rather than loading a game thatwill explode later. A save file is machine-written, so an out-of-range
Whichcan only mean corruption or tampering.3. Defensive guards at the listed dispatch sites
None of these change behavior for any in-range value; each is an
if !obj.hasValidWhich()short-circuit around the existing code:sticks.go:34zapHandlers[…]nilhandler: no effect,Charges--still runs (C's non-MASTERotherwise)potions.go:45quaffHandlers[…]nilhandler: no effect (C's quaff switch had no default)scrolls.go:32readHandlers[…]nilhandlerpotions.go:51,scrolls.go:39callIt(&Items.X[Which])scrolls.go:170-172(readIdentify, incl. thedata.idType[…]index the issue table missed)things.go:53nameScrollinventoryName, which coversnameScrollplus its siblings (PotColors/RingStones/WandType/WandMade/Sticks/Weapons/Armorsindexes); falls back to the bareObjectKind.String()category namewizard.go:74aClass[Which](*gameData).armorClass(which)accessor, also used atrip.go:149,things.go:115,potions.go:268so the guard is actually completeweapons.go:162initWeaps[which]initWeaponfor an out-of-range kindsticks.go:505Items.WandType[Which]infixStick1x1); theswitchbelow already has adefault4. Tests (
game/wizard_test.go, new)//nolint:testpackageheader,t.Parallel()in every test:testTermscripted"/f",createObj(), assert nopanic and that nothing was added to the pack;
Which: input below'a'(e.g.'_') and below'0'(e.g.'!');doZap), potion (quaff), scroll(
readScroll), armor (createObjarmor path), weapon (initWeapon) —each with a deliberately malformed pack object, asserting no panic;
Whichis refused;in-range behavior.
TestSeedCompatItemTablesis the RNG-order canary; it must stay greenuntouched.
5. Bookkeeping
make fmt,make checkgreen.TODO.mdgets a Completed Steps entry inthe same commit; per the PR #9 precedent for out-of-band issue work, Next
Step is left alone (it is still the unfinished coverage-broadening step).
Commit title ends with
(closes #10).