aboutsummaryrefslogtreecommitdiff
path: root/internal/game/account_txn.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/account_txn.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/game/account_txn.go')
-rw-r--r--internal/game/account_txn.go90
1 files changed, 81 insertions, 9 deletions
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go
index cf31dea..13f7695 100644
--- a/internal/game/account_txn.go
+++ b/internal/game/account_txn.go
@@ -121,9 +121,36 @@ func (g *Game) canonicalChar(name string) (string, error) {
return g.AccountStore.FindCharacter(name)
}
+// validateAccountName and validateCharacterName reject names that any
+// AccountPath/CharPath join would interpret as a path (separators, dots) or
+// that no account/character in this store may carry. Every lifecycle
+// transaction validates its names BEFORE it touches the filesystem: a
+// caller-supplied or account-file-supplied name such as "../../x" would
+// otherwise make AccountPath/CharPath read or delete files outside
+// players/, and a transaction is not the place to discover that.
+func validateAccountName(name string) error {
+ if err := player.ValidName(name); err != nil {
+ return fmt.Errorf("invalid account name %q: %w", name, err)
+ }
+ return nil
+}
+
+func validateCharacterName(name string) error {
+ if err := player.ValidName(name); err != nil {
+ return fmt.Errorf("invalid character name %q: %w", name, err)
+ }
+ return nil
+}
+
// createCharacterTxn creates the character file and appends it to the
// owning account in a single transaction. Returns the created player.
func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int) (*player.Player, error) {
+ if err := validateAccountName(accountName); err != nil {
+ return nil, err
+ }
+ if err := validateCharacterName(charName); err != nil {
+ return nil, err
+ }
p := player.New(charName)
p.RoomID = startingRoom
p.Stats.RecordRoomVisit(startingRoom)
@@ -155,6 +182,15 @@ func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int
// renameCharacterTxn atomically renames the character file, persists the new
// name inside it, and updates the owning account's character list.
func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error {
+ if err := validateAccountName(accountName); err != nil {
+ return err
+ }
+ if err := validateCharacterName(oldName); err != nil {
+ return err
+ }
+ if err := validateCharacterName(newName); err != nil {
+ return err
+ }
txn := g.newTxn()
if err := txn.Move(g.AccountStore.CharPath(oldName), g.AccountStore.CharPath(newName)); err != nil {
return err
@@ -202,6 +238,12 @@ func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error {
// deleteCharacterTxn removes the character file and updates the account list
// atomically.
func (g *Game) deleteCharacterTxn(accountName, charName string) error {
+ if err := validateAccountName(accountName); err != nil {
+ return err
+ }
+ if err := validateCharacterName(charName); err != nil {
+ return err
+ }
txn := g.newTxn()
if err := txn.Delete(g.AccountStore.CharPath(charName)); err != nil {
return err
@@ -228,8 +270,19 @@ func (g *Game) deleteCharacterTxn(accountName, charName string) error {
}
// purgeAccountTxn removes the account file and every character file in one
-// transaction.
+// transaction. The account's character list arrives from the account file,
+// so every name in it is validated before the first CharPath join: a
+// hand-edited or corrupt entry must not turn a purge into a delete outside
+// players/characters/.
func (g *Game) purgeAccountTxn(accountName string, characters []string) error {
+ if err := validateAccountName(accountName); err != nil {
+ return err
+ }
+ for _, name := range characters {
+ if err := validateCharacterName(name); err != nil {
+ return err
+ }
+ }
txn := g.newTxn()
for _, name := range characters {
if err := txn.Delete(g.AccountStore.CharPath(name)); err != nil {
@@ -247,15 +300,30 @@ func (g *Game) purgeAccountTxn(accountName string, characters []string) error {
// transaction. Without the character rewrite the renamed account loses
// access to its characters: membership authorization compares the
// character file's owner against the account name.
+//
+// Both names and every entry of the account's character list are validated
+// up front: each one is joined into AccountPath/CharPath below, and a
+// path-like name would escape players/.
func (g *Game) renameAccountTxn(oldName, newName string) error {
- txn := g.newTxn()
- if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil {
+ if err := validateAccountName(oldName); err != nil {
+ return err
+ }
+ if err := validateAccountName(newName); err != nil {
return err
}
acc, err := g.AccountStore.LoadAccount(oldName)
if err != nil {
return err
}
+ for _, charName := range acc.Characters {
+ if err := validateCharacterName(charName); err != nil {
+ return err
+ }
+ }
+ txn := g.newTxn()
+ if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil {
+ return err
+ }
acc.Name = newName
accData, err := g.AccountStore.MarshalAccount(acc)
if err != nil {
@@ -311,7 +379,9 @@ func (g *Game) isMemberOf(accountName, characterName string) bool {
// publishAccountUpdate reloads the account from disk and republishes its
// state to every session of that account, resetting stale pending menu
// selections (pending character names, menu choices) after lifecycle
-// mutations. Called from the owner goroutine only.
+// mutations. A session whose account no longer exists on disk is reset to the
+// account-name prompt (resetToAccountName) and closed. Called from the owner
+// goroutine only.
//
// oldName and newName differ only for an account rename: sessions
// authenticated before the rename still carry the OLD cached account
@@ -321,12 +391,14 @@ func (g *Game) publishAccountUpdate(oldName, newName string) {
for _, sess := range g.allSessionsOfEitherAccount(oldName, newName) {
acc, err := g.AccountStore.LoadAccount(newName)
if err != nil {
- // The account file vanished (purge from another session); the
- // session is stale and must leave the menu.
- sess.Account = nil
- sess.PendingChar = ""
- sess.PendingMenu = nil
+ // The account file vanished (purge or a raw admin delete from
+ // another session); the session is stale and must leave the menu.
+ // resetToAccountName clears PendingPass and the pending state
+ // alongside Account: a stale confirm-password still matching
+ // PendingPass would otherwise reach a handler that expects an
+ // account bound to the session.
sess.WriteLine("\nYour account no longer exists.")
+ g.resetToAccountName(sess)
sess.Close()
continue
}