1 Commits

Author SHA1 Message Date
9dbd9d11cb fix: bound wizard-created Which against its item table (closes #10)
createObj stored the raw 0-f nibble as Object.Which with no bounds
check, so wizard mode -> C -> / -> f made a wand numbered 15 against a
14-entry table and panicked in fixStick; input below 'a' or '0' went
negative and panicked the same way. C's create_obj() was equally
unchecked, but its consumers were either switches (defined for any
value) or static-array reads past the end (undefined, and survivable in
practice). Since one game is now one process, the Go panic kills the
game outright and leaves the terminal in raw mode.

Reject at the two boundaries a bad Which can enter through. createObj
now refuses an out-of-range choice with a message drawn from C's own
type_name() vocabulary and adds nothing to the pack, a deliberate
divergence recorded in a comment because C had no defined behavior here
to be faithful to. Restore refuses a snapshot describing such an object
(ErrSaveCorrupt) rather than loading a game that would explode later.

Behind those, whichLimit/hasValidWhich back defensive guards at every
dispatch the issue names: the quaffHandler/readHandler/zapHandler
accessors return no handler instead of indexing (for wands that is
exactly what non-MASTER C did, matching no case and still running
o_charges--), the callIt lore lookups, identifyType, armorClass for the
a_class[] reads, initWeapon against the missing init_dam[] row for
WeaponFlame, fixStick's ws_type[] read, and inventoryName and
objectWorth, hoisted so one check each covers the whole family of
per-kind name and appraisal tables.

No in-range input changes behavior and no guard consumes a random
number: the rejection precedes every rnd() call. TestSeedCompatItemTables
stays green untouched.

New game/wizard_test.go covers the exact reproducer, a rejection sweep
over every indexed kind including both negative-input forms, an
acceptance sweep proving valid choices still build the right item, one
no-panic test per guarded family, the fixStick crash site, the
corrupt-save rejection, and a check that whichLimit still agrees with
the table sizes. Each guard was confirmed load-bearing by reverting it
and watching the test panic.

TODO.md records the step; Next Step is deliberately left alone, since
this arrived out of band via an issue.
2026-08-09 05:09:03 +00:00
6 changed files with 72 additions and 117 deletions

73
TODO.md
View File

@@ -37,45 +37,40 @@ wizard commands).
- 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10): - 2026-08-09 Wizard-create bounds fix (`fix/wizard-which-bounds`, closes #10):
`createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds `createObj` stored the raw `0-f` nibble as `Object.Which` with no bounds
check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against check, so wizard mode -> `C` -> `/` -> `f` produced a wand numbered 15 against
a 14-entry table and panicked in `fixStick`. Input outside `0-f` overshoots a 14-entry table and panicked in `fixStick`; input below `'a'` or `'0'` went
much further rather than going negative: `readchar` returns a `byte`, so the negative (`'A'` gives -22, `'!'` gives -54) and panicked the same way. C's
`int(ch-'a') + 10` branch is byte arithmetic and wraps — `'A'` gives 234 and `create_obj()` was equally unchecked, but every C consumer was either a
`'!'` gives 202 — and panicked the same way. C's `create_obj()` was equally `switch` (defined for any value) or a static-array read past the end
unchecked, but every C consumer was either a `switch` (defined for any value) (undefined, and survivable in practice), whereas since refactor step 8 one
or a static-array read past the end (undefined, and survivable in practice), game is one process, so the Go panic kills the game with the terminal still in
whereas since refactor step 8 one game is one process, so the Go panic kills raw mode. Fixed at the two boundaries a bad `Which` can enter through:
the game with the terminal still in raw mode. Fixed at the two boundaries a `createObj` now rejects an out-of-range choice with a message built from C's
bad `Which` can enter through: `createObj` now rejects an out-of-range choice own `type_name()` vocabulary and adds nothing to the pack (a deliberate,
with a message built from C's own `type_name()` vocabulary and adds nothing to commented divergence, since C had no defined behavior here to be faithful to),
the pack (a deliberate, commented divergence, since C had no defined behavior and `Restore` refuses a snapshot describing such an object (`ErrSaveCorrupt`)
here to be faithful to), and `Restore` refuses a snapshot describing such an instead of loading a game that would explode later. Behind those,
object (`ErrSaveCorrupt`) instead of loading a game that would explode later. `whichLimit`/`hasValidWhich` back defensive guards at every dispatch named in
Behind those, `whichLimit`/`hasValidWhich` back defensive guards at every the issue: the three effect tables (the new `quaffHandler`, `readHandler`, and
dispatch named in the issue: the three effect tables (the new `quaffHandler`, `zapHandler` accessors return no handler rather than indexing — for wands that
`readHandler`, and `zapHandler` accessors return no handler rather than is exactly non-`MASTER` C, which matched no case and still ran `o_charges--`),
indexing — for wands that is exactly non-`MASTER` C, which matched no case and the `callIt` lore lookups, `identifyType` (whose table is shorter than the
still ran `o_charges--`), the `callIt` lore lookups, `identifyType` (whose scroll table keying it), `armorClass` for all four `a_class[]` reads,
table is shorter than the scroll table keying it, though no scroll that can `initWeapon` against the missing `init_dam[]` row for `WeaponFlame`,
reach `readIdentify` overshoots it, so that one is defensive rather than a `fixStick`'s `ws_type[]` read, and `inventoryName`, hoisted so one check
live bound), `armorClass` for the four `a_class[]` reads, `initWeapon` against covers the scroll-title read the issue listed plus its potion-color,
the missing `init_dam[]` row for `WeaponFlame`, `fixStick`'s `ws_type[]` read, ring-stone, wand-material, weapon and armor siblings. `objectWorth` got the
and `inventoryName`, hoisted so one check covers the scroll-title read the same hoisted guard, since the death-screen appraisal reads the identical
issue listed plus its potion-color, ring-stone, wand-material, weapon and per-kind tables. No in-range input changes behavior and no guard consumes a
armor siblings. `objectWorth` got the same hoisted guard, since the random number — the rejection precedes every `rnd()` call, verified both by an
death-screen appraisal reads the identical per-kind tables. No in-range input explicit seed-unchanged test and by `TestSeedCompatItemTables` staying green
changes behavior and no guard consumes a random number — the rejection untouched. New `game/wizard_test.go`: the exact reproducer, a rejection sweep
precedes every `rnd()` call, verified both by an explicit seed-unchanged test over every indexed kind including both negative-input forms, an acceptance
and by `TestSeedCompatItemTables` staying green untouched. New sweep proving valid choices still build the right item, one no-panic test per
`game/wizard_test.go`: the exact reproducer, a rejection sweep over every guarded family (wand/potion/scroll/armor/weapon), the `fixStick` crash site,
indexed kind including both wrapping-input forms, an acceptance sweep proving the corrupt-save rejection, and a check that `whichLimit` still agrees with
valid choices still build the right item, one no-panic test per guarded family the table sizes. Each guard was confirmed load-bearing by reverting it and
(wand/potion/scroll/armor/weapon), the `fixStick` crash site, the corrupt-save watching the test panic. `Next Step` deliberately not rotated: this was
rejection over the wrapped values and a negative `Which` (a decoded snapshot out-of-band issue work.
is the only source of one, so it is what exercises the `Which >= 0` arm of
`hasValidWhich`), and a check that `whichLimit` still agrees with the table
sizes. Each guard was confirmed load-bearing by reverting it and watching the
test panic. `Next Step` deliberately not rotated: this was out-of-band issue
work.
- 2026-08-09 Stale-docs correction (`docs-staleness`, closes #3): four claims in - 2026-08-09 Stale-docs correction (`docs-staleness`, closes #3): four claims in
`MEMORY.md`/`TODO.md`/`README.md` had gone false and were misdirecting agents `MEMORY.md`/`TODO.md`/`README.md` had gone false and were misdirecting agents

View File

@@ -219,13 +219,6 @@ func (o *Object) ArmorKind() ArmorKind { return ArmorKind(o.Which) }
// satisfy it; the wizard-create command and a restored save file are the // satisfy it; the wizard-create command and a restored save file are the
// only two ways a malformed one can appear, and both reject it (see // only two ways a malformed one can appear, and both reject it (see
// createObj and validateSnapshotObjects). // createObj and validateSnapshotObjects).
//
// The Which >= 0 arm is unreachable from the keyboard: createObj derives
// Which with byte arithmetic (int(ch-'a') + 10), which wraps to a large
// positive value rather than going negative. It is kept as
// defense-in-depth for the non-keyboard source — a decoded save file,
// where Which is an int off the wire and can hold anything — and is
// exercised there by TestRestoreRejectsOutOfRangeWhich.
func (o *Object) hasValidWhich() bool { func (o *Object) hasValidWhich() bool {
limit := whichLimit(o.Kind) limit := whichLimit(o.Kind)

View File

@@ -169,12 +169,10 @@ func (g *RogueGame) createMonsterSpot() (Coord, bool) {
} }
func (g *RogueGame) readIdentify(obj *Object) { func (g *RogueGame) readIdentify(obj *Object) {
// Identify, let him figure something out. idType is shorter than the // Identify, let him figure something out. readHandler has already
// scroll table keying it (it stops after the last identify scroll), // bounded Which against the scroll table to get here, but idType is
// so the filter lookup carries its own bound. That bound is not // shorter than that table (it stops after the last identify scroll),
// reachable today: readHandlers registers readIdentify only for the // so the filter lookup carries its own bound.
// identify scrolls, all of which sit inside idType. It is kept as
// defense-in-depth against a future table resize.
g.Items.Scrolls[obj.Which].Know = true g.Items.Scrolls[obj.Which].Know = true
g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name) g.msg("this scroll is an %s scroll", g.Items.Scrolls[obj.Which].Name)
g.whatis(true, g.data.identifyType(obj.ScrollKind())) g.whatis(true, g.data.identifyType(obj.ScrollKind()))

View File

@@ -897,10 +897,7 @@ func (d *gameData) zapHandler(obj *Object) func(g *RogueGame, obj *Object) bool
// identifyType reads extern.c's identify-scroll to item-kind map, // identifyType reads extern.c's identify-scroll to item-kind map,
// returning KindNone for any scroll past the last identify scroll (the // returning KindNone for any scroll past the last identify scroll (the
// table is shorter than the scroll table it is keyed by). No scroll that // table is shorter than the scroll table it is keyed by).
// can reach readIdentify is past that end, so the guard is defensive
// rather than a live bound; it exists so a future table resize cannot
// turn the lookup into a panic.
func (d *gameData) identifyType(kind ScrollKind) ObjectKind { func (d *gameData) identifyType(kind ScrollKind) ObjectKind {
if kind < 0 || int(kind) >= len(d.idType) { if kind < 0 || int(kind) >= len(d.idType) {
return KindNone return KindNone

View File

@@ -27,16 +27,14 @@ func (g *RogueGame) createObj() {
g.Msgs.Mpos = 0 g.Msgs.Mpos = 0
// Deliberate divergence from 5.4.4: C stored this nibble unchecked, so // Deliberate divergence from 5.4.4: C stored this nibble unchecked, so
// 'a'-'f' indexed straight past the ends of the per-kind static // 'a'-'f' (and anything below '0' or 'a', which goes negative) indexed
// tables. Input outside '0'-'9' and 'a'-'f' overshoots much further: // straight past the ends of the per-kind static tables. That was
// readchar returns a byte, so ch-'a' above is byte arithmetic and // undefined behavior C happened to survive by reading adjacent memory;
// wraps instead of going negative ('A' gives 234, '!' gives 202). // in Go it is a panic that kills the process with the terminal still in
// Reading past a static array was undefined behavior C happened to // raw mode. C had no defined behavior here to be faithful to, so the
// survive by picking up adjacent memory; in Go it is a panic that // choice is rejected outright rather than emulating a garbage read. The
// kills the process with the terminal still in raw mode. C had no // check precedes every rnd() call below, so the RNG sequence is
// defined behavior here to be faithful to, so the choice is rejected // untouched either way.
// outright rather than emulating a garbage read. The check precedes
// every rnd() call below, so the RNG sequence is untouched either way.
if !obj.wizardCanCreate() { if !obj.wizardCanCreate() {
g.msg("there is no such %s", obj.Kind) g.msg("there is no such %s", obj.Kind)

View File

@@ -47,12 +47,9 @@ func TestCreateObjWandFReproducer(t *testing.T) {
} }
// TestCreateObjRejectsOutOfRangeWhich sweeps the rejection across every // TestCreateObjRejectsOutOfRangeWhich sweeps the rejection across every
// kind whose Which is a table index, including input outside '0'-'f'. // kind whose Which is a table index, including the negative values that
// isDigit is false for such input, so it takes the letter branch, where // input below 'a' and below '0' produce ('A'-'a'+10 == -22, '!'-'a'+10 ==
// ch-'a' is byte arithmetic and wraps rather than going negative: 'A' // -54; isDigit is false for both, so both take the letter branch).
// (65) gives int(224)+10 == 234 and '!' (33) gives int(192)+10 == 202.
// Those far-past-the-end values, not negative ones, are what the guard
// has to catch on the keyboard path.
func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) { func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) {
t.Parallel() t.Parallel()
@@ -65,14 +62,14 @@ func TestCreateObjRejectsOutOfRangeWhich(t *testing.T) {
{"potion f is past NumPotionTypes", Potion, 'f'}, {"potion f is past NumPotionTypes", Potion, 'f'},
{"ring f is past NumRingTypes", Ring, 'f'}, {"ring f is past NumRingTypes", Ring, 'f'},
// NumScrollTypes is 18, past the 'f' the prompt tops out at, so a // NumScrollTypes is 18, past the 'f' the prompt tops out at, so a
// scroll can only be driven out of range by input that wraps. // scroll can only be driven out of range by going negative.
{"scroll 'A' wraps to 234", Scroll, 'A'}, {"negative below 'a' for scroll", Scroll, 'A'},
{"armor 9 is past NumArmorTypes", Armor, '9'}, {"armor 9 is past NumArmorTypes", Armor, '9'},
{"weapon 9 is the flame pseudo-weapon", Weapon, '9'}, {"weapon 9 is the flame pseudo-weapon", Weapon, '9'},
{"wand 'A' wraps to 234", Stick, 'A'}, {"negative: letter below 'a'", Stick, 'A'},
{"wand '!' wraps to 202", Stick, '!'}, {"negative: character below '0'", Stick, '!'},
{"armor 'A' wraps to 234", Armor, 'A'}, {"negative below 'a' for armor", Armor, 'A'},
{"weapon '!' wraps to 202", Weapon, '!'}, {"negative below '0' for weapon", Weapon, '!'},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -318,53 +315,30 @@ func TestFixStickMalformedWhichDoesNotPanic(t *testing.T) {
// TestRestoreRejectsOutOfRangeWhich is the save-file half of the fix: a // TestRestoreRejectsOutOfRangeWhich is the save-file half of the fix: a
// malformed object must not be able to sneak past the keyboard guard by // malformed object must not be able to sneak past the keyboard guard by
// arriving in a snapshot. // arriving in a snapshot.
//
// A decoded save is also the only place a *negative* Which can come
// from. On the keyboard path createObj's ch-'a' is byte arithmetic and
// wraps, so 'A' and '!' land at 234 and 202; Which is a plain int in the
// gob stream, so a tampered file can carry any value at all. Both shapes
// are covered here, and the negative case is what exercises the
// Which >= 0 arm of hasValidWhich.
func TestRestoreRejectsOutOfRangeWhich(t *testing.T) { func TestRestoreRejectsOutOfRangeWhich(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct { g := mkGame(t, 11)
name string st := g.snapshot()
which int
}{ if len(st.Player.Body.Pack) == 0 {
{"one past the wand table", int(NumWandTypes)}, t.Fatal("starting pack is empty; nothing to corrupt")
{"the value 'A' wraps to on the keyboard path", 234},
{"the value '!' wraps to on the keyboard path", 202},
{"negative, reachable only from a tampered file", -1},
} }
for _, tc := range cases { st.Player.Body.Pack[0].Kind = KindWand
t.Run(tc.name, func(t *testing.T) { st.Player.Body.Pack[0].Which = int(NumWandTypes)
t.Parallel()
g := mkGame(t, 11) path := filepath.Join(t.TempDir(), "rogue.save")
st := g.snapshot() writeSnapshot(t, path, st)
if len(st.Player.Body.Pack) == 0 { _, restoreErr := Restore(path, Params{Term: &testTerm{}})
t.Fatal("starting pack is empty; nothing to corrupt") if !errors.Is(restoreErr, ErrSaveCorrupt) {
} t.Errorf("Restore error = %v, want ErrSaveCorrupt", restoreErr)
}
st.Player.Body.Pack[0].Kind = KindWand _, statErr := os.Stat(path)
st.Player.Body.Pack[0].Which = tc.which if statErr != nil {
t.Error("a rejected save file was deleted; it should be left alone")
path := filepath.Join(t.TempDir(), "rogue.save")
writeSnapshot(t, path, st)
_, restoreErr := Restore(path, Params{Term: &testTerm{}})
if !errors.Is(restoreErr, ErrSaveCorrupt) {
t.Errorf("Restore error = %v, want ErrSaveCorrupt", restoreErr)
}
_, statErr := os.Stat(path)
if statErr != nil {
t.Error("a rejected save file was deleted; it should be left alone")
}
})
} }
} }