- add character imprint, awakening, and equipment refinement - add equipment ticket gacha and persistent progression fields - add request-scoped account transactions with crash recovery - persist age confirmation and isolate client plugin patches - separate one-off save repair tools from shipped tooling
201 lines
4.9 KiB
Go
201 lines
4.9 KiB
Go
// Package statetx provides a crash-safe write-ahead transaction around one
|
|
// account request. It knows filenames and bytes only; domain stores retain all
|
|
// JSON and gameplay semantics.
|
|
package statetx
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const journalName = ".account-transaction"
|
|
|
|
type Coordinator struct {
|
|
mu sync.Mutex
|
|
root string
|
|
allowed map[string]bool
|
|
failed bool
|
|
cache map[string]fileDigest
|
|
}
|
|
|
|
type fileDigest struct {
|
|
info os.FileInfo
|
|
digest string
|
|
}
|
|
|
|
// Open validates the account boundary and recovers an interrupted request
|
|
// before any domain store reads its state.
|
|
func Open(root string, allowed []string) (*Coordinator, error) {
|
|
if len(allowed) == 0 {
|
|
return nil, errors.New("statetx: no state filenames")
|
|
}
|
|
s := &Coordinator{root: filepath.Clean(root), allowed: make(map[string]bool, len(allowed)), cache: make(map[string]fileDigest, len(allowed))}
|
|
for _, name := range allowed {
|
|
if err := validName(name); err != nil || s.allowed[name] {
|
|
return nil, fmt.Errorf("statetx: invalid or duplicate allowed filename %q", name)
|
|
}
|
|
s.allowed[name] = true
|
|
}
|
|
if err := s.recoverLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.refreshCacheLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Coordinator) refreshCacheLocked() error {
|
|
next := make(map[string]fileDigest, len(s.allowed))
|
|
for name := range s.allowed {
|
|
path := filepath.Join(s.root, name)
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return fmt.Errorf("statetx: %s is not a regular state file", name)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
next[name] = fileDigest{info: info, digest: digest(data)}
|
|
}
|
|
s.cache = next
|
|
return nil
|
|
}
|
|
|
|
// Check is the request dispatcher fail-stop gate. Once memory could differ
|
|
// from a disk generation restored after an error, only restart may continue.
|
|
func (s *Coordinator) Check() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.failed {
|
|
return errors.New("statetx: account persistence requires restart recovery")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validName(name string) error {
|
|
if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\:`) || name == journalName {
|
|
return errors.New("filename is not a direct child")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func digest(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (s *Coordinator) recoverLocked() error {
|
|
preparing := filepath.Join(s.root, journalName+".preparing")
|
|
if info, err := os.Lstat(preparing); err == nil {
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return errors.New("statetx: preparing path is not a directory")
|
|
}
|
|
if err := os.RemoveAll(preparing); err != nil {
|
|
return err
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
journal := filepath.Join(s.root, journalName)
|
|
info, err := os.Lstat(journal)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return errors.New("statetx: journal is not a directory")
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(journal, "prepared.json")); err == nil {
|
|
return s.recoverRequestLocked(journal)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
// Begin writes undo.json and then prepared before gameplay may execute.
|
|
// Therefore a journal without undo.json cannot have touched live state.
|
|
return os.RemoveAll(journal)
|
|
}
|
|
|
|
func writeAtomicSynced(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
file, err := os.CreateTemp(dir, ".statetx-phase-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := file.Name()
|
|
defer os.Remove(name)
|
|
if _, err = file.Write(data); err == nil {
|
|
err = file.Sync()
|
|
}
|
|
if closeErr := file.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := replaceFile(name, path); err != nil {
|
|
return err
|
|
}
|
|
return syncDir(dir)
|
|
}
|
|
|
|
func writeSynced(path string, data []byte) error {
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = f.Write(data)
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
if closeErr := f.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
return err
|
|
}
|
|
|
|
func copySynced(source, destination string) error {
|
|
data, err := os.ReadFile(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeSynced(destination, data)
|
|
}
|
|
|
|
func replaceFromImage(image, target string) error {
|
|
data, err := os.ReadFile(image)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.CreateTemp(filepath.Dir(target), ".account-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(file.Name())
|
|
if _, err = file.Write(data); err == nil {
|
|
err = file.Sync()
|
|
}
|
|
if closeErr := file.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return replaceFile(file.Name(), target)
|
|
}
|